diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index c10e43a..579dae4 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -282,7 +282,12 @@ defmodule LinearCli.CLI.Commands do } with {:ok, issues} <- Linear.issues(input) do - Display.show(issues, %{output: options.output, full: flags.full}) + Display.show(issues, %{ + output: options.output, + full: flags.full, + labels: input.labels != [] + }) + :ok end end diff --git a/app/lib/linear_cli/cli/display.ex b/app/lib/linear_cli/cli/display.ex index 4f8f3fa..88527ad 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -66,8 +66,8 @@ defmodule LinearCli.CLI.Display do issue_full(issue) end - defp format(%Issue{} = issue, _opts) do - issue_line(issue) + defp format(%Issue{} = issue, opts) do + issue_line(issue, opts) end defp user_line(user, opts) do @@ -80,10 +80,16 @@ defmodule LinearCli.CLI.Display do end end - defp issue_line(issue) do + defp issue_line(issue, opts \\ %{}) do state = if issue.state, do: "[#{issue.state.name}] ", else: "" basic = "#{String.pad_trailing(issue.identifier || "", 12)} #{state}#{issue.title}" - if issue.assignee, do: "#{basic} (#{issue.assignee.name})", else: basic + line = if issue.assignee, do: "#{basic} (#{issue.assignee.name})", else: basic + + if Map.get(opts, :labels) && issue.labels != [] do + "#{line} [#{Enum.map_join(issue.labels, ", ", & &1.name)}]" + else + line + end end defp issue_full(issue) do diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index 7fbb497..644b6bd 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -88,6 +88,11 @@ defmodule LinearCli.Linear.Issue do "team { #{LinearCli.Linear.Team.base_fields()} }" end + @doc "GraphQL field selection for base_fields plus labels — used when listing issues filtered by label." + def list_fields_with_labels do + "#{base_fields()} labels { nodes { #{LinearCli.Linear.Label.base_fields()} } }" + end + @doc "GraphQL field selection for a fully detailed issue, incl. comments (Ruby: Issue.full_fragment)." def full_fields do "#{@issue_fields} " <> @@ -137,7 +142,7 @@ defmodule LinearCli.Linear.Issue.Read.List do if args.ids != [] do find_by_ids(args.ids) else - list_all(build_filter(args)) + list_all(build_filter(args), args.labels != []) end end @@ -161,6 +166,19 @@ defmodule LinearCli.Linear.Issue.Read.List do """ end + # Same paginated query but includes labels in each node — used when the caller + # filtered by --labels so we can show labels in the compact output. + defp list_document_with_labels do + """ + query($filter: IssueFilter, $first: Int!, $after: String) { + issues(filter: $filter, first: $first, after: $after) { + edges { node { #{Issue.list_fields_with_labels()} } cursor } + pageInfo { hasNextPage endCursor } + } + } + """ + end + # Ruby: params[:ids].map { |id| Issue.find(id) } - fully serial. Each id is # an independent GraphQL call, so fan them out concurrently instead. Capped # at 20 in flight to avoid hammering the API on a very long id list; ordered @@ -187,9 +205,11 @@ defmodule LinearCli.Linear.Issue.Read.List do end end - defp list_all(filter) do + defp list_all(filter, include_labels) do + document = if include_labels, do: list_document_with_labels(), else: list_document() + Paginate.all( - list_document(), + document, "issues", fn after_cursor -> %{"filter" => filter, "first" => 50, "after" => after_cursor} end, &Issue.from_map/1 diff --git a/app/test/linear_cli/cli/display_test.exs b/app/test/linear_cli/cli/display_test.exs index bd8449a..4153e05 100644 --- a/app/test/linear_cli/cli/display_test.exs +++ b/app/test/linear_cli/cli/display_test.exs @@ -88,4 +88,57 @@ defmodule LinearCli.CLI.DisplayTest do refute output =~ "Labels:" end + + test "compact listing appends labels in brackets when labels opt is true" do + issue = %Issue{ + id: "issue-4", + identifier: "EXT-4", + title: "Labelled compact", + description: nil, + 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, %{labels: true}) end) + + assert output =~ "EXT-4" + assert output =~ "[Bug, Feature]" + end + + test "compact listing omits label brackets when labels opt is false" do + issue = %Issue{ + id: "issue-5", + identifier: "EXT-5", + title: "Unlabelled compact", + description: nil, + comments: [], + labels: [ + %Label{id: "l1", name: "Bug", description: nil, is_group: false} + ] + } + + output = capture_io(fn -> Display.show(issue, %{labels: false}) end) + + assert output =~ "EXT-5" + refute output =~ "[Bug]" + end + + test "compact listing omits label brackets when issue has no labels even if labels opt is true" do + issue = %Issue{ + id: "issue-6", + identifier: "EXT-6", + title: "No labels", + description: nil, + comments: [], + labels: [] + } + + output = capture_io(fn -> Display.show(issue, %{labels: true}) end) + + assert output =~ "EXT-6" + refute output =~ "[" + 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 7763c6c..479704e 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -606,6 +606,96 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "[Done]" assert output =~ "Fix the thing" end + + test "-l shows label names in compact output" do + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "-l", "Bug"]) + end) + + assert output =~ "CRY-1" + assert output =~ "[Bug]" + end + + test "--labels shows label names in compact output" do + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Bug"]) + end) + + assert output =~ "CRY-1" + assert output =~ "[Bug]" + end + + test "--labels with multiple labels shows all label names in compact output" do + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false}, + %{"id" => "l2", "name" => "Feature", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Bug,Feature"]) + end) + + assert output =~ "CRY-1" + assert output =~ "[Bug, Feature]" + end + + test "compact listing without --labels does not show label brackets" do + labeled_issue = + issue_map(%{ + "labels" => %{ + "nodes" => [ + %{"id" => "l1", "name" => "Bug", "description" => nil, "isGroup" => false} + ] + } + }) + + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issues_response([labeled_issue])) + end) + + output = capture_io(fn -> assert :ok = LinearCli.CLI.main(["issue", "list"]) end) + + assert output =~ "CRY-1" + refute output =~ "[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 30eaeeb..e3e5875 100644 --- a/app/test/linear_cli/linear/issue_test.exs +++ b/app/test/linear_cli/linear/issue_test.exs @@ -557,6 +557,72 @@ defmodule LinearCli.Linear.IssueTest do end describe "issues/1 label filtering" do + test "issues/1 with labels requests labels fields in the GraphQL query" 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(%{labels: ["Bug"]}) + end + + test "issues/1 without labels does not request labels fields in the GraphQL query" 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 {") + + Req.Test.json(conn, %{ + "data" => %{"issues" => %{"edges" => [], "pageInfo" => %{"hasNextPage" => false}}} + }) + end) + + assert {:ok, []} = Linear.issues(%{labels: [], mine: false}) + end + + test "issues/1 with labels parses label names from the response" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, %{ + "data" => %{ + "issues" => %{ + "edges" => [ + %{ + "node" => %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Bug fix", + "branchName" => "cry-1-bug-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(%{labels: ["Bug"]}) + assert [label] = issue.labels + assert label.name == "Bug" + end + test "issues/1 with labels: [single] sends some/name/eqIgnoreCase filter" do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn)