diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 0e9699f..7c637a2 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -46,7 +46,9 @@ defmodule LinearCli.CLI do # `parse_result` is bound above, outside this try, specifically so # it's still in scope here (bindings from inside a `do` block aren't # visible in that same try's `rescue`, but outer-scope bindings are). - exception -> handle_error(exception, parse_result.options[:debug], halt) + exception -> + debug = is_struct(parse_result, Optimus.ParseResult) && parse_result.options[:debug] + handle_error(exception, debug, halt) end end @@ -507,7 +509,11 @@ defmodule LinearCli.CLI do long: "--no-mine", help: "List the most recent issues, not just your own" ], - full: [short: "-f", long: "--full", help: "Show full issue details"] + full: [short: "-f", long: "--full", help: "Show full issue details"], + all: [ + long: "--all", + help: "Show all issues including completed and cancelled" + ] ], options: [ team: [short: "-t", long: "--team", help: "Show issues for only this team"], @@ -516,6 +522,25 @@ defmodule LinearCli.CLI do long: "--project", help: "Show issues for only this project. Can be name, URL, ID, or - to select from a list" + ], + 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 ] ] ], diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index cb30893..97d752f 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -234,7 +234,9 @@ defmodule LinearCli.CLI.Commands do mine: !flags.no_mine, unassigned: flags.unassigned, team_key: team_key, - project_id: project_id + project_id: project_id, + all: Map.get(flags, :all, false), + status: Map.get(options, :status) || [] } 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 5d7d615..63df9c6 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -13,6 +13,8 @@ defmodule LinearCli.Linear.Issue do argument :unassigned, :boolean, default: false argument :team_key, :string, allow_nil?: true argument :project_id, :string, allow_nil?: true + argument :all, :boolean, default: false + argument :status, {:array, :string}, default: [] manual LinearCli.Linear.Issue.Read.List end @@ -174,11 +176,46 @@ 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. defp build_filter(args) do - %{"completedAt" => %{"null" => true}, "canceledAt" => %{"null" => true}} + %{} + |> maybe_put_date_filters(args) |> maybe_put_assignee_filter(args) |> maybe_put_team_filter(args) |> maybe_put_project_filter(args) + |> maybe_put_state_filter(args) + end + + @completed_types ~w(completed) + @cancelled_types ~w(cancelled canceled) + + defp maybe_put_date_filters(filter, %{all: true}), do: filter + + defp maybe_put_date_filters(filter, %{status: status}) when status != [] do + filter + |> maybe_put_completed_date_filter(status) + |> maybe_put_cancelled_date_filter(status) + end + + 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)), + 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)), + do: filter, + else: Map.put(filter, "canceledAt", %{"null" => true}) end defp maybe_put_assignee_filter(filter, %{unassigned: true}) do @@ -202,6 +239,12 @@ defmodule LinearCli.Linear.Issue.Read.List do end 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}}) + end + + defp maybe_put_state_filter(filter, _args), do: filter 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 5f97c8f..a5510a0 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -201,6 +201,103 @@ defmodule LinearCli.CLI.IssueCommandsTest do output = capture_io(fn -> assert :ok = LinearCli.CLI.main(["issue", "list"]) end) assert output =~ "CRY-1" end + + test "--all removes completedAt and canceledAt null-check filters" 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", "--all"]) + end) + + assert_received {:filter, filter} + refute Map.has_key?(filter, "completedAt") + refute Map.has_key?(filter, "canceledAt") + end + + test "--status filters by workflow state type and removes corresponding date filters" 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", "started"]) + end) + + assert_received {:filter, filter} + assert filter["state"] == %{"type" => %{"in" => ["started"]}} + # "started" is not completed/cancelled so both date filters remain + assert Map.has_key?(filter, "completedAt") + assert Map.has_key?(filter, "canceledAt") + end + + test "--status completed removes completedAt filter but keeps canceledAt filter" 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", "completed"]) + end) + + assert_received {:filter, filter} + assert filter["state"] == %{"type" => %{"in" => ["completed"]}} + refute Map.has_key?(filter, "completedAt") + assert Map.has_key?(filter, "canceledAt") + end + + test "--status accepts multiple comma-separated types" 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", "started,completed"]) + end) + + assert_received {:filter, filter} + assert filter["state"] == %{"type" => %{"in" => ["started", "completed"]}} + refute Map.has_key?(filter, "completedAt") + assert Map.has_key?(filter, "canceledAt") + end + + test "--status with an unknown type exits 1 (Optimus parse error)" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + # Optimus catches the bad value and calls halt.(1); with a fake halt that + # doesn't terminate the process, execution continues and eventually crashes + # (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) + rescue + _ -> :ok + end + + assert_received {:halted, 1} + end end describe "issue create (Ruby: commands/issue/create.rb)" do