diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 5db243c..0bdb0da 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -488,7 +488,18 @@ defmodule LinearCli.CLI do defp parse_statuses(value), do: {:ok, split_filter_values(value)} - defp parse_labels(value), do: {:ok, split_filter_values(value)} + defp parse_labels(value) do + values = split_filter_values(value) + bad = Enum.find(values, &String.starts_with?(&1, "-")) + parse_labels_result(values, bad) + end + + defp parse_labels_result(values, nil), do: {:ok, values} + + defp parse_labels_result(_values, bad), + do: + {:error, + "invalid value #{inspect(bad)} for --labels: looks like an option token, not a label name"} defp split_filter_values(value) do value @@ -699,6 +710,11 @@ defmodule LinearCli.CLI do all: [ long: "--all", help: "Show all issues including completed and cancelled" + ], + include_labels: [ + short: "-i", + long: "--include-labels", + help: "Include labels in issue list output" ] ], options: [ diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 807eb02..a0a42c6 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -269,6 +269,9 @@ defmodule LinearCli.CLI.Commands do options.project || unless no_profile, do: Profiles.default_project() with {:ok, project_id} <- resolve_project_id(project_source, team_key) do + label_filter = Map.get(options, :labels) || [] + include_labels = Map.get(flags, :include_labels, false) || label_filter != [] + input = %{ ids: Enum.map(ids, &IssueHelpers.expand_issue_id/1), mine: !flags.no_mine, @@ -278,14 +281,15 @@ defmodule LinearCli.CLI.Commands do all: Map.get(flags, :all, false), state: Map.get(options, :state) || [], status: Map.get(options, :status) || [], - labels: Map.get(options, :labels) || [] + labels: label_filter, + include_labels: include_labels } with {:ok, issues} <- Linear.issues(input) do Display.show(issues, %{ output: options.output, full: flags.full, - labels: input.labels != [] + labels: include_labels }) :ok diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index dcf3f98..36bd9f2 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -17,6 +17,7 @@ defmodule LinearCli.Linear.Issue do argument :state, {:array, :string}, default: [] argument :status, {:array, :string}, default: [] argument :labels, {:array, :string}, default: [] + argument :include_labels, :boolean, default: false manual LinearCli.Linear.Issue.Read.List end @@ -159,7 +160,7 @@ defmodule LinearCli.Linear.Issue.Read.List do if args.ids != [] do find_by_ids(args.ids) else - list_all(build_filter(args), args.labels != []) + list_all(build_filter(args), args.include_labels || args.labels != []) end end diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index efad05b..1cf8983 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -701,6 +701,281 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "CRY-1" refute output =~ "[Bug]" end + + test "--include-labels requests label fields and shows them in compact output" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + send(test_pid, {:query, query}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--include-labels"]) + end) + + assert_received {:query, query} + assert String.contains?(query, "labels") + assert output =~ "CRY-1" + assert output =~ "[Bug]" + end + + test "-i short flag requests label fields and shows them in compact output" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + send(test_pid, {:query, query}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "-i"]) + end) + + assert_received {:query, query} + assert String.contains?(query, "labels") + assert output =~ "CRY-1" + assert output =~ "[Bug]" + end + + test "-N --include-labels --all sends no assignee/date filter, no label filter, requests label fields" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Feature", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + send(test_pid, {:query, decoded["query"]}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "list", + "-N", + "--include-labels", + "--all" + ]) + end) + + assert_received {:filter, filter} + assert_received {:query, query} + refute Map.has_key?(filter, "assignee") + refute Map.has_key?(filter, "completedAt") + refute Map.has_key?(filter, "canceledAt") + refute Map.has_key?(filter, "labels") + assert String.contains?(query, "labels") + assert output =~ "CRY-1" + assert output =~ "[Feature]" + end + + test "-N -i --all is the same as -N --include-labels --all" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Feature", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "-N", "-i", "--all"]) + end) + + assert_received {:filter, filter} + refute Map.has_key?(filter, "assignee") + refute Map.has_key?(filter, "labels") + assert output =~ "[Feature]" + end + + test "--labels --all exits 1 (Optimus parse error, no API request)" do + Req.Test.stub(LinearCli.Api, fn _conn -> raise "no GraphQL call should happen" end) + + output = + capture_io(fn -> + assert catch_throw( + LinearCli.CLI.main( + ["issue", "list", "--labels", "--all"], + fn code -> throw({:halted, code}) end + ) + ) == {:halted, 1} + end) + + assert output =~ "--labels" + end + + test "-s/--status composes with --include-labels" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + send(test_pid, {:filter, decoded["variables"]["filter"]}) + send(test_pid, {:query, decoded["query"]}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "list", + "--status", + "Human Review", + "--include-labels" + ]) + end) + + assert_received {:filter, filter} + assert_received {:query, query} + assert filter["state"] == %{"name" => %{"eqIgnoreCase" => "Human Review"}} + assert String.contains?(query, "labels") + assert output =~ "CRY-1" + assert output =~ "[Bug]" + end + + test "positional issue identifier with --include-labels uses full lookup and renders labels" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + send(test_pid, {:query, query}) + + Req.Test.json(conn, %{ + "data" => %{ + "issue" => + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{ + "id" => "l1", + "name" => "Bug", + "description" => nil, + "isGroup" => false + } + ] + }, + "comments" => %{"nodes" => []}, + "relations" => %{ + "edges" => [], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + }, + "inverseRelations" => %{ + "edges" => [], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + }) + } + }) + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "list", "CRY-1", "--include-labels"]) + end) + + assert_received {:query, query} + assert String.contains?(query, "issue(id: $id)") + assert output =~ "CRY-1" + assert output =~ "Bug" + end + + test "--output json --include-labels contains fetched label objects in stdout" do + test_pid = self() + + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + send(test_pid, {:query, query}) + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "list", + "--output", + "json", + "--include-labels" + ]) + end) + + assert_received {:query, query} + assert String.contains?(query, "labels") + assert {:ok, [decoded]} = Jason.decode(output) + assert [label] = decoded["labels"] + assert label["name"] == "Bug" + end end describe "issue view" do diff --git a/app/test/linear_cli/linear/issue_test.exs b/app/test/linear_cli/linear/issue_test.exs index e3e5875..0cc30e0 100644 --- a/app/test/linear_cli/linear/issue_test.exs +++ b/app/test/linear_cli/linear/issue_test.exs @@ -677,4 +677,78 @@ defmodule LinearCli.Linear.IssueTest do assert {:ok, []} = Linear.issues(%{labels: [], mine: false}) end end + + describe "issues/1 include_labels field selection" do + test "include_labels: true, labels: [] requests label fields without adding a label filter" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query, "variables" => %{"filter" => filter}} = Jason.decode!(body) + + assert String.contains?(query, "labels") + refute Map.has_key?(filter, "labels") + + Req.Test.json(conn, %{ + "data" => %{ + "issues" => %{ + "edges" => [ + %{ + "node" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix", + "branchName" => "cry-1-fix", + "description" => nil, + "assignee" => nil, + "state" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }, + "cursor" => "c1" + } + ], + "pageInfo" => %{"hasNextPage" => false} + } + } + }) + end) + + assert {:ok, [issue]} = Linear.issues(%{include_labels: true, labels: [], mine: false}) + assert [label] = issue.labels + assert label.name == "Bug" + end + + test "include_labels: false, labels: [] uses the base selection without label fields" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + refute String.contains?(query, "labels { nodes") + + Req.Test.json(conn, %{ + "data" => %{"issues" => %{"edges" => [], "pageInfo" => %{"hasNextPage" => false}}} + }) + end) + + assert {:ok, []} = Linear.issues(%{include_labels: false, labels: [], mine: false}) + end + + test "non-empty labels defensively selects label fields even when include_labels is false" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + assert String.contains?(query, "labels") + + Req.Test.json(conn, %{ + "data" => %{"issues" => %{"edges" => [], "pageInfo" => %{"hasNextPage" => false}}} + }) + end) + + assert {:ok, []} = Linear.issues(%{include_labels: false, labels: ["Bug"], mine: false}) + end + end end diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index a8fd711..773eac2 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -260,7 +260,8 @@ data, not from declared Ash relationships. === Notes on unidirectional associations * `Label` is stored as an association attribute on `Issue` (`labels`, `{:array, :term}`) — - populated from `issue.labels.nodes` in `full_fields` responses only. + populated from `issue.labels.nodes` in `full_fields` responses and in compact list + responses when `include_labels: true` or `labels` is non-empty (via `list_fields_with_labels/0`). 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. @@ -351,10 +352,12 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `:list` | read | `Linear.Issue.Read.List` -| `issues(filter:, first:, after:)` paginated (base), with `state` filtering - workflow-state types, `status` filtering friendly workflow-state names, and - `labels` filtering by label name (case-insensitive OR match via - `IssueLabelCollectionFilter.some`); or `issue(id:)` per id (full, fanned concurrently) +| `issues(filter:, first:, after:)` paginated; `state` filters workflow-state types, + `status` filters friendly workflow-state names, `labels` filters by label name + (case-insensitive OR match via `IssueLabelCollectionFilter.some`); `include_labels` + (boolean, default `false`) selects label fields in the response without adding a + label filter — `labels` non-empty also forces label-field selection as a defensive + invariant; or `issue(id:)` per id (full, fanned concurrently) | `Issue` | `create_issue`