diff --git a/Readme.adoc b/Readme.adoc index ab8cf95..62cc591 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -200,7 +200,15 @@ $ lc whoami --teams <1> $ lcls $ lcls --full $ lcls -f CRY-1 ----- +$ lcls -N <1> +$ lcls --state started <2> +$ lcls --status "Human Review" <3> +$ lcls --state started --status "Human Review,Gate Approved" <4> +---- +<1> Include issues not assigned to you (short for `--no-mine`) +<2> Filter by Linear's internal workflow state type +<3> Filter by a friendly workflow status name (case-insensitive) +<4> Combine state and status filters; both must match ==== Assign one or more issues to yourself (take em!) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index e7e7bee..d9afa72 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -9,6 +9,8 @@ defmodule LinearCli.CLI do alias LinearCli.CLI.Commands + @workflow_state_types ~w(triage backlog unstarted started completed canceled duplicate) + def main(argv, halt \\ &System.halt/1) do argv = argv @@ -424,6 +426,34 @@ defmodule LinearCli.CLI do defp maybe_print_backtrace(_debug), do: :ok + defp parse_states(value) do + states = + value + |> split_filter_values() + |> Enum.map(&normalize_state/1) + + case Enum.find(states, &(&1 not in @workflow_state_types)) do + nil -> + {:ok, states} + + state -> + {:error, + "unknown state #{inspect(state)}, must be one of: #{Enum.join(@workflow_state_types, ", ")}"} + end + end + + defp parse_statuses(value), do: {:ok, split_filter_values(value)} + + defp split_filter_values(value) do + value + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp normalize_state("cancelled"), do: "canceled" + defp normalize_state(state), do: String.downcase(state) + def spec do Optimus.new!( name: "lc", @@ -473,7 +503,11 @@ defmodule LinearCli.CLI do name: "list", about: "List teams", flags: [ - no_mine: [long: "--no-mine", help: "List all teams, not just your own"], + no_mine: [ + short: "-N", + long: "--no-mine", + help: "List all teams, not just your own" + ], all: [long: "--all", help: "Ignore favorites (doesn't affect --no-mine)"] ] ], @@ -607,6 +641,7 @@ defmodule LinearCli.CLI do help: "Show unassigned issues only" ], no_mine: [ + short: "-N", long: "--no-mine", help: "List the most recent issues, not just your own" ], @@ -628,24 +663,17 @@ defmodule LinearCli.CLI do help: "Show issues for only this project. Can be name, URL, ID, or - to select from a list" ], + state: [ + long: "--state", + help: + "Filter by workflow state type(s): triage, backlog, unstarted, started, completed, canceled, duplicate (comma-separated)", + parser: &parse_states/1 + ], status: [ short: "-s", long: "--status", - help: - "Filter by workflow state type(s): triage, backlog, unstarted, started, completed, cancelled (comma-separated)", - parser: fn v -> - valid = ~w(triage backlog unstarted started completed cancelled canceled) - types = String.split(v, ",", trim: true) - - case Enum.find(types, &(&1 not in valid)) do - nil -> - {:ok, types} - - bad -> - {:error, - "unknown status #{inspect(bad)}, must be one of: #{Enum.join(valid, ", ")}"} - end - end + help: "Filter by friendly workflow status name(s) (comma-separated)", + parser: &parse_statuses/1 ] ] ], diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index dcc802e..959056c 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -270,6 +270,7 @@ defmodule LinearCli.CLI.Commands do team_key: team_key, project_id: project_id, all: Map.get(flags, :all, false), + state: Map.get(options, :state) || [], status: Map.get(options, :status) || [] } diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index da752fb..f371091 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -14,6 +14,7 @@ defmodule LinearCli.Linear.Issue do argument :team_key, :string, allow_nil?: true argument :project_id, :string, allow_nil?: true argument :all, :boolean, default: false + argument :state, {:array, :string}, default: [] argument :status, {:array, :string}, default: [] manual LinearCli.Linear.Issue.Read.List end @@ -188,9 +189,9 @@ defmodule LinearCli.Linear.Issue.Read.List do # Ported from Rubyists::Linear::Operations::Issue::List#build_filter. `unassigned` # is checked after `mine` here too, so it wins if both are set - same as Ruby. # `all: true` removes the completedAt/canceledAt null-checks so closed/cancelled - # issues are included. `status` injects a state.type filter; when it includes - # "completed" or "cancelled"/"canceled", the corresponding date null-checks are - # also dropped so those issues aren't filtered out before the type filter applies. + # issues are included. A type filter only removes the date guard for the closed + # state it requests. A friendly-name filter removes both guards because its type + # is unknown until Linear evaluates it. defp build_filter(args) do %{} |> maybe_put_date_filters(args) @@ -201,30 +202,30 @@ defmodule LinearCli.Linear.Issue.Read.List do end @completed_types ~w(completed) - @cancelled_types ~w(cancelled canceled) + @cancelled_types ~w(cancelled canceled duplicate) defp maybe_put_date_filters(filter, %{all: true}), do: filter - defp maybe_put_date_filters(filter, %{status: status}) when status != [] do + defp maybe_put_date_filters(filter, %{state: [_ | _] = states}) do filter - |> maybe_put_completed_date_filter(status) - |> maybe_put_cancelled_date_filter(status) + |> maybe_put_completed_date_filter(states) + |> maybe_put_cancelled_date_filter(states) end + defp maybe_put_date_filters(filter, %{status: [_ | _]}), do: filter + defp maybe_put_date_filters(filter, _args) do Map.merge(filter, %{"completedAt" => %{"null" => true}, "canceledAt" => %{"null" => true}}) end - # Suppress the completedAt null-check only when the status list doesn't ask for completed. - defp maybe_put_completed_date_filter(filter, status) do - if Enum.any?(status, &(&1 in @completed_types)), + defp maybe_put_completed_date_filter(filter, states) do + if Enum.any?(states, &(&1 in @completed_types)), do: filter, else: Map.put(filter, "completedAt", %{"null" => true}) end - # Suppress the canceledAt null-check only when the status list doesn't ask for cancelled. - defp maybe_put_cancelled_date_filter(filter, status) do - if Enum.any?(status, &(&1 in @cancelled_types)), + defp maybe_put_cancelled_date_filter(filter, states) do + if Enum.any?(states, &(&1 in @cancelled_types)), do: filter, else: Map.put(filter, "canceledAt", %{"null" => true}) end @@ -251,11 +252,33 @@ defmodule LinearCli.Linear.Issue.Read.List do defp maybe_put_project_filter(filter, _args), do: filter - defp maybe_put_state_filter(filter, %{status: [_ | _] = types}) do - Map.put(filter, "state", %{"type" => %{"in" => types}}) + defp maybe_put_state_filter(filter, %{state: [], status: []}), do: filter + + defp maybe_put_state_filter(filter, %{state: states, status: statuses}) do + state_filter = + %{} + |> maybe_put_state_types(states) + |> maybe_put_status_names(statuses) + + Map.put(filter, "state", state_filter) + end + + defp maybe_put_state_types(filter, []), do: filter + + defp maybe_put_state_types(filter, states) do + Map.put(filter, "type", %{"in" => states}) end - defp maybe_put_state_filter(filter, _args), do: filter + defp maybe_put_status_names(filter, []), do: filter + + defp maybe_put_status_names(filter, [status]) do + Map.put(filter, "name", %{"eqIgnoreCase" => status}) + end + + defp maybe_put_status_names(filter, statuses) do + names = Enum.map(statuses, &%{"name" => %{"eqIgnoreCase" => &1}}) + Map.put(filter, "or", names) + 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 d65c31c..b23ff3c 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -257,6 +257,24 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "CRY-1" end + test "-N aliases --no-mine" 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", "-N"]) + end) + + assert_received {:filter, filter} + refute Map.has_key?(filter, "assignee") + end + test "--all removes completedAt and canceledAt null-check filters" do test_pid = self() @@ -276,7 +294,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do refute Map.has_key?(filter, "canceledAt") end - test "--status filters by workflow state type and removes corresponding date filters" do + test "--state filters by workflow state type and removes corresponding date filters" do test_pid = self() Req.Test.stub(LinearCli.Api, fn conn -> @@ -287,7 +305,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do end) capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--status", "started"]) + assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "started"]) end) assert_received {:filter, filter} @@ -297,7 +315,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert Map.has_key?(filter, "canceledAt") end - test "--status completed removes completedAt filter but keeps canceledAt filter" do + test "--state completed removes completedAt filter but keeps canceledAt filter" do test_pid = self() Req.Test.stub(LinearCli.Api, fn conn -> @@ -308,7 +326,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do end) capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--status", "completed"]) + assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "completed"]) end) assert_received {:filter, filter} @@ -317,7 +335,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert Map.has_key?(filter, "canceledAt") end - test "--status accepts multiple comma-separated types" do + test "--state accepts multiple comma-separated types" do test_pid = self() Req.Test.stub(LinearCli.Api, fn conn -> @@ -328,7 +346,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do end) capture_io(fn -> - assert :ok = LinearCli.CLI.main(["issue", "list", "--status", "started,completed"]) + assert :ok = LinearCli.CLI.main(["issue", "list", "--state", "started,completed"]) end) assert_received {:filter, filter} @@ -337,6 +355,62 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert Map.has_key?(filter, "canceledAt") end + test "--status filters by friendly workflow status name" 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", "--status", "Human Review"]) + end) + + assert_received {:filter, filter} + assert filter["state"] == %{"name" => %{"eqIgnoreCase" => "Human Review"}} + refute Map.has_key?(filter, "completedAt") + refute Map.has_key?(filter, "canceledAt") + end + + test "--state and comma-separated --status values combine as type AND friendly name" 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", + "--state", + "started", + "--status", + "Human Review, Gate Approved" + ]) + end) + + assert_received {:filter, filter} + + assert filter["state"] == %{ + "type" => %{"in" => ["started"]}, + "or" => [ + %{"name" => %{"eqIgnoreCase" => "Human Review"}}, + %{"name" => %{"eqIgnoreCase" => "Gate Approved"}} + ] + } + + assert Map.has_key?(filter, "completedAt") + assert Map.has_key?(filter, "canceledAt") + end + test "--no-profile bypasses active profile defaults via the full CLI dispatch path" do test_pid = self() @@ -364,7 +438,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do refute Map.has_key?(filter, "project") end - test "--status with an unknown type exits 1 (Optimus parse error)" do + test "--state with an unknown type exits 1 (Optimus parse error)" do test_pid = self() halt = fn code -> send(test_pid, {:halted, code}) end @@ -373,7 +447,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do # (same artifact as the --help test in cli_test.exs). Rescue it so the test # can still verify halt was called with the right code. try do - LinearCli.CLI.main(["issue", "list", "--status", "badtype"], halt) + LinearCli.CLI.main(["issue", "list", "--state", "badtype"], halt) rescue _ -> :ok end diff --git a/app/test/linear_cli/cli_test.exs b/app/test/linear_cli/cli_test.exs index f5ff571..6068b4b 100644 --- a/app/test/linear_cli/cli_test.exs +++ b/app/test/linear_cli/cli_test.exs @@ -98,8 +98,8 @@ defmodule LinearCli.CLITest do assert capture_io(fn -> LinearCli.CLI.main(["team", "list"]) end) =~ "Engineering" end - test "team list --no-mine lists all teams" do - assert capture_io(fn -> LinearCli.CLI.main(["team", "list", "--no-mine"]) end) =~ "Ops" + test "team list -N aliases --no-mine" do + assert capture_io(fn -> LinearCli.CLI.main(["team", "list", "-N"]) end) =~ "Ops" end test "project list defaults to all projects (Ruby: --mine defaults false)" do diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 87ca54f..2120baa 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -312,7 +312,9 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `:list` | read | `Linear.Issue.Read.List` -| `issues(filter:, first:, after:)` paginated (base) or `issue(id:)` per id (full, fanned concurrently) +| `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) | `Issue` | `create_issue`