diff --git a/app/lib/linear_cli/cli/display.ex b/app/lib/linear_cli/cli/display.ex index 405a154..4f8f3fa 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -89,12 +89,18 @@ defmodule LinearCli.CLI.Display do defp issue_full(issue) do header = issue_line(issue) sep = String.duplicate("-", String.length(header)) + labels = labels_line(issue.labels) description = render_markdown(issue.description) comments = Enum.map_join(issue.comments, "\n", &comment_block/1) - Enum.join([header, sep, description, comments], "\n") + [header, sep, labels, description, comments] + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") end + defp labels_line([]), do: "" + defp labels_line(labels), do: "Labels: #{Enum.map_join(labels, ", ", & &1.name)}" + defp comment_block(comment) do user = (comment.user && comment.user.name) || "unknown" "--- #{user} ---\n#{render_markdown(comment.body)}" diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index f57fee9..7fbb497 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -74,6 +74,7 @@ defmodule LinearCli.Linear.Issue do attribute :state, :term, public?: true attribute :team, :term, public?: true attribute :comments, {:array, :term}, public?: true, default: [] + attribute :labels, {:array, :term}, public?: true, default: [] end @issue_fields "id identifier title branchName description url createdAt updatedAt" @@ -93,7 +94,8 @@ defmodule LinearCli.Linear.Issue do "state { #{@state_fields} } " <> "assignee { #{LinearCli.Linear.User.fields_with_teams()} } " <> "team { #{LinearCli.Linear.Team.full_fields()} } " <> - "comments { nodes { #{LinearCli.Linear.Comment.base_fields()} } }" + "comments { nodes { #{LinearCli.Linear.Comment.base_fields()} } } " <> + "labels { nodes { #{LinearCli.Linear.Label.base_fields()} } }" end @doc false @@ -112,6 +114,11 @@ defmodule LinearCli.Linear.Issue do Enum.map( get_in(map, ["comments", "nodes"]) || [], &LinearCli.Linear.Comment.from_map/1 + ), + labels: + Enum.map( + get_in(map, ["labels", "nodes"]) || [], + &LinearCli.Linear.Label.from_map/1 ) ) end diff --git a/app/test/linear_cli/cli/display_test.exs b/app/test/linear_cli/cli/display_test.exs index 616673d..bd8449a 100644 --- a/app/test/linear_cli/cli/display_test.exs +++ b/app/test/linear_cli/cli/display_test.exs @@ -4,7 +4,7 @@ defmodule LinearCli.CLI.DisplayTest do import ExUnit.CaptureIO alias LinearCli.CLI.Display - alias LinearCli.Linear.Issue + alias LinearCli.Linear.{Issue, Label} test "full issue output syntax-highlights fenced Elixir code" do issue = %Issue{ @@ -55,4 +55,37 @@ defmodule LinearCli.CLI.DisplayTest do assert output =~ theme.syntax.name_function <> "hello" <> theme.reset refute output =~ theme.code_text <> "class Greeter" end + + test "full issue output includes a Labels line when labels are present" do + issue = %Issue{ + id: "issue-2", + identifier: "EXT-2", + title: "Labelled issue", + description: "Some work", + comments: [], + labels: [ + %Label{id: "l1", name: "Bug", description: nil, is_group: false}, + %Label{id: "l2", name: "Feature", description: nil, is_group: false} + ] + } + + output = capture_io(fn -> Display.show(issue, %{full: true}) end) + + assert output =~ "Labels: Bug, Feature" + end + + test "full issue output omits the Labels line when no labels are present" do + issue = %Issue{ + id: "issue-3", + identifier: "EXT-3", + title: "Unlabelled issue", + description: "Some work", + comments: [], + labels: [] + } + + output = capture_io(fn -> Display.show(issue, %{full: true}) end) + + refute output =~ "Labels:" + end end diff --git a/app/test/linear_cli/linear/issue_test.exs b/app/test/linear_cli/linear/issue_test.exs index d86ee81..b8823f0 100644 --- a/app/test/linear_cli/linear/issue_test.exs +++ b/app/test/linear_cli/linear/issue_test.exs @@ -106,6 +106,7 @@ defmodule LinearCli.Linear.IssueTest do assert id == "CRY-2" assert query =~ "issue(id: $id)" assert query =~ "comments" + assert query =~ "labels" Req.Test.json(conn, %{ "data" => %{ @@ -117,7 +118,8 @@ defmodule LinearCli.Linear.IssueTest do "description" => nil, "assignee" => nil, "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, - "comments" => %{"nodes" => []} + "comments" => %{"nodes" => []}, + "labels" => %{"nodes" => []} } } }) @@ -125,6 +127,49 @@ defmodule LinearCli.Linear.IssueTest do assert {:ok, [issue]} = Linear.issues(%{ids: ["cry-2"]}) assert issue.identifier == "CRY-2" + assert issue.labels == [] + end + + test "issues/1 with ids parses labels from the full-detail response" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => %{"id" => _id}} = Jason.decode!(body) + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "id" => "i2", + "identifier" => "CRY-2", + "title" => "Ship it", + "branchName" => "cry-2-ship-it", + "description" => nil, + "assignee" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []}, + "labels" => %{ + "nodes" => [ + %{ + "id" => "lbl-1", + "name" => "Bug", + "description" => nil, + "isGroup" => false + }, + %{ + "id" => "lbl-2", + "name" => "Feature", + "description" => "A new feature", + "isGroup" => false + } + ] + } + } + } + }) + end) + + assert {:ok, [issue]} = Linear.issues(%{ids: ["cry-2"]}) + assert length(issue.labels) == 2 + assert Enum.map(issue.labels, & &1.name) == ["Bug", "Feature"] end test "issues/1 parses the issue's current state when present in the response" do diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 540c77d..9cf1f69 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -72,6 +72,7 @@ erDiagram WorkflowState state Team team Comment[] comments + Label[] labels } Label { string id PK @@ -103,6 +104,7 @@ erDiagram Issue }o--|| WorkflowState : "state [nested]" Issue }o--|| Team : "team [nested]" Issue ||--o{ Comment : "comments [nested]" + Issue ||--o{ Label : "labels [nested]" User }o--o{ Team : "teams [nested]" Comment }o--|| User : "user/author [nested]" Project }o--o{ Team : "teams [nested]" @@ -140,7 +142,7 @@ Eight resources are registered in `LinearCli.Linear` | `LinearCli.Linear.Issue` | `id` (`:string`) -| `identifier`, `title`, `branch_name`, `description`, `assignee` (`:term`), `state` (`:term`), `team` (`:term`), `comments` (`{:array, :term}`) +| `identifier`, `title`, `branch_name`, `description`, `assignee` (`:term`), `state` (`:term`), `team` (`:term`), `comments` (`{:array, :term}`), `labels` (`{:array, :term}`) | `LinearCli.Linear.Label` | `id` (`:string`) @@ -196,6 +198,12 @@ data, not from declared Ash relationships. | `comments` (`{:array, :term}`) | GraphQL/nested-data — populated by `Issue.from_map/1` from `issue.comments.nodes` (full fragment only) +| `Issue` +| `Label` +| one-to-many +| `labels` (`{:array, :term}`) +| GraphQL/nested-data — populated by `Issue.from_map/1` from `issue.labels.nodes` (full fragment only) + | `User` | `Team` | many-to-many @@ -224,9 +232,13 @@ data, not from declared Ash relationships. === Notes on unidirectional associations -* `Label` and `WorkflowState` have no stored association attributes. - Their `team_id` appears only as a query argument passed to their read - actions; there is no `:team` field on those structs. +* `Label` is stored as an association attribute on `Issue` (`labels`, `{:array, :term}`) — + populated from `issue.labels.nodes` in `full_fields` responses only. + However, `Label` itself has no stored back-reference to its team; the + `team_id` is passed as an action argument to `Label.Read.ByTeam` only. +* `WorkflowState` has no stored association attributes. + Its `team_id` appears only as a query argument passed to its read + actions; there is no `:team` field on that struct. * `ProjectUpdate` has no stored association attributes. Its `project_id` appears only as a required create argument. * When `Team.Read.Find` fetches a team by id, the response includes @@ -401,8 +413,8 @@ All four issue-update actions (`:assign`, `:attach_to_project`, `:close`, `:set_status`) delegate to this shared runner rather than each building their own `issueUpdate` mutation. `run/2` calls the mutation, receives the updated issue map, and decodes it via `Issue.from_map/1` using -`Issue.full_fields/0` (the full fragment including assignee, team, and -comments). +`Issue.full_fields/0` (the full fragment including assignee, team, +comments, and labels). The mutation GraphQL document is built at call time (not a module attribute) because `Issue.full_fields/0` references `User`, `Team`, and