diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 15a197c..0d6ce1b 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -453,6 +453,8 @@ 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 split_filter_values(value) do value |> String.split(",", trim: true) @@ -683,6 +685,12 @@ defmodule LinearCli.CLI do long: "--status", help: "Filter by friendly workflow status name(s) (comma-separated)", parser: &parse_statuses/1 + ], + labels: [ + short: "-l", + long: "--labels", + help: "Filter by label name(s) (comma-separated, OR match)", + parser: &parse_labels/1 ] ] ], diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 44873f1..4bf1a82 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -277,7 +277,8 @@ defmodule LinearCli.CLI.Commands do project_id: project_id, all: Map.get(flags, :all, false), state: Map.get(options, :state) || [], - status: Map.get(options, :status) || [] + status: Map.get(options, :status) || [], + labels: Map.get(options, :labels) || [] } with {:ok, issues} <- Linear.issues(input) do diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index 84ed535..f57fee9 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -16,6 +16,7 @@ defmodule LinearCli.Linear.Issue do argument :all, :boolean, default: false argument :state, {:array, :string}, default: [] argument :status, {:array, :string}, default: [] + argument :labels, {:array, :string}, default: [] manual LinearCli.Linear.Issue.Read.List end @@ -201,6 +202,7 @@ defmodule LinearCli.Linear.Issue.Read.List do |> maybe_put_team_filter(args) |> maybe_put_project_filter(args) |> maybe_put_state_filter(args) + |> maybe_put_label_filter(args) end @completed_types ~w(completed) @@ -281,6 +283,17 @@ defmodule LinearCli.Linear.Issue.Read.List do names = Enum.map(statuses, &%{"name" => %{"eqIgnoreCase" => &1}}) Map.put(filter, "or", names) end + + defp maybe_put_label_filter(filter, %{labels: []}), do: filter + + defp maybe_put_label_filter(filter, %{labels: [label]}) do + Map.put(filter, "labels", %{"some" => %{"name" => %{"eqIgnoreCase" => label}}}) + end + + defp maybe_put_label_filter(filter, %{labels: labels}) do + or_clauses = Enum.map(labels, &%{"name" => %{"eqIgnoreCase" => &1}}) + Map.put(filter, "labels", %{"some" => %{"or" => or_clauses}}) + end end defmodule LinearCli.Linear.Issue.Create do diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 5cc5a32..51a3bb5 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -455,6 +455,102 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert_received {:halted, 1} end + test "--labels filters by a single label name (case-insensitive)" do + test_pid = self() + + 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([issue_map()])) + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Incident-followup"]) + end) + + assert_received {:filter, filter} + + assert filter["labels"] == %{ + "some" => %{"name" => %{"eqIgnoreCase" => "Incident-followup"}} + } + end + + test "--labels accepts comma-separated names and matches issues with any of them (OR)" do + test_pid = self() + + 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([issue_map()])) + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "Bug,Feature"]) + end) + + assert_received {:filter, filter} + + assert filter["labels"] == %{ + "some" => %{ + "or" => [ + %{"name" => %{"eqIgnoreCase" => "Bug"}}, + %{"name" => %{"eqIgnoreCase" => "Feature"}} + ] + } + } + end + + test "--labels with an unknown label name returns empty result, not a crash" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, issues_response([])) + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--labels", "no-such-label"]) + end) + + assert output == "" or is_binary(output) + end + + test "--labels composes with --team" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + + if String.contains?(decoded["query"] || "", "teams(") do + Req.Test.json(conn, %{ + "data" => %{ + "teams" => %{ + "edges" => [ + %{ + "node" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "cursor" => "c1" + } + ], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => "c1"} + } + } + }) + else + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--team", "ENG", "--labels", "Bug"]) + end) + + assert_received {:filter, filter} + assert Map.has_key?(filter, "team") + assert filter["labels"] == %{"some" => %{"name" => %{"eqIgnoreCase" => "Bug"}}} + end + test "compact listing includes workflow state name in brackets" do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) diff --git a/app/test/linear_cli/linear/issue_test.exs b/app/test/linear_cli/linear/issue_test.exs index 2dc19a2..d86ee81 100644 --- a/app/test/linear_cli/linear/issue_test.exs +++ b/app/test/linear_cli/linear/issue_test.exs @@ -62,6 +62,42 @@ defmodule LinearCli.Linear.IssueTest do assert {:ok, []} = Linear.issues(%{unassigned: true}) end + test "issues/1 with labels sends the correct label filter to the API" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => %{"filter" => filter}} = Jason.decode!(body) + assert filter["labels"] == %{"some" => %{"name" => %{"eqIgnoreCase" => "Bug"}}} + + Req.Test.json(conn, %{ + "data" => %{"issues" => %{"edges" => [], "pageInfo" => %{"hasNextPage" => false}}} + }) + end) + + assert {:ok, []} = Linear.issues(%{labels: ["Bug"]}) + end + + test "issues/1 with multiple labels sends OR filter to the API" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => %{"filter" => filter}} = Jason.decode!(body) + + assert filter["labels"] == %{ + "some" => %{ + "or" => [ + %{"name" => %{"eqIgnoreCase" => "Bug"}}, + %{"name" => %{"eqIgnoreCase" => "Feature"}} + ] + } + } + + Req.Test.json(conn, %{ + "data" => %{"issues" => %{"edges" => [], "pageInfo" => %{"hasNextPage" => false}}} + }) + end) + + assert {:ok, []} = Linear.issues(%{labels: ["Bug", "Feature"]}) + end + test "issues/1 with ids fetches each by id via the full-detail query" do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) @@ -468,4 +504,60 @@ defmodule LinearCli.Linear.IssueTest do Linear.update_issue_description(issue, "Updated body") end end + + describe "issues/1 label filtering" do + 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) + %{"variables" => %{"filter" => filter}} = Jason.decode!(body) + + assert filter["labels"] == %{ + "some" => %{"name" => %{"eqIgnoreCase" => "Bug"}} + } + + Req.Test.json(conn, %{ + "data" => %{"issues" => %{"edges" => [], "pageInfo" => %{"hasNextPage" => false}}} + }) + end) + + assert {:ok, []} = Linear.issues(%{labels: ["Bug"]}) + end + + test "issues/1 with labels: [multiple] sends some/or filter for OR semantics" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => %{"filter" => filter}} = Jason.decode!(body) + + assert filter["labels"] == %{ + "some" => %{ + "or" => [ + %{"name" => %{"eqIgnoreCase" => "Bug"}}, + %{"name" => %{"eqIgnoreCase" => "Feature"}} + ] + } + } + + Req.Test.json(conn, %{ + "data" => %{"issues" => %{"edges" => [], "pageInfo" => %{"hasNextPage" => false}}} + }) + end) + + assert {:ok, []} = Linear.issues(%{labels: ["Bug", "Feature"]}) + end + + test "issues/1 with labels: [] sends no labels filter key" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => %{"filter" => filter}} = Jason.decode!(body) + + refute Map.has_key?(filter, "labels") + + Req.Test.json(conn, %{ + "data" => %{"issues" => %{"edges" => [], "pageInfo" => %{"hasNextPage" => false}}} + }) + end) + + assert {:ok, []} = Linear.issues(%{labels: [], mine: false}) + end + end end diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 2120baa..540c77d 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -313,8 +313,9 @@ manual-implementation module, and the Linear GraphQL operation it calls. | read | `Linear.Issue.Read.List` | `issues(filter:, first:, after:)` paginated (base), with `state` filtering - workflow-state types and `status` filtering friendly workflow-state names; or - `issue(id:)` per id (full, fanned concurrently) + 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) | `Issue` | `create_issue`