From 2cb0a6536a556895051be7a8f96ea86f8d4b2f1f Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Fri, 25 Sep 2026 22:36:18 -0400 Subject: [PATCH 1/5] feat(issue): support filter-only batch unassignment --- Readme.adoc | 17 +- app/lib/linear_cli/cli.ex | 49 ++- .../linear_cli/cli/commands/issues/filter.ex | 71 ++++ .../cli/commands/issues/mutations.ex | 101 +++++- .../linear_cli/cli/commands/issues/read.ex | 53 +-- app/lib/linear_cli/linear/issue.ex | 18 +- app/lib/linear_cli/linear/paginate.ex | 20 +- .../cli/commands/issues/mutations_test.exs | 309 +++++++++++++++++- .../linear_cli/cli/profile_defaults_test.exs | 60 ++++ app/test/linear_cli/linear/paginate_test.exs | 103 ++++++ app/test/support/issue_commands_helpers.ex | 11 + documents/ash-domain-erd.adoc | 23 +- 12 files changed, 759 insertions(+), 76 deletions(-) create mode 100644 app/lib/linear_cli/cli/commands/issues/filter.ex create mode 100644 app/test/linear_cli/linear/paginate_test.exs diff --git a/Readme.adoc b/Readme.adoc index 7815be2..1e91034 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -289,17 +289,30 @@ $ lc issue assign CRY-1234 <4> ==== Unassign one or more issues This command clears the assignee from each listed issue. It accepts explicit -issue identifiers only. +issue identifiers or a filter-only selection. Filter mode requires at least one +narrowing selector: `--assignee`, `--team`, `--project`, `--state`, `--status`, +or `--labels`. `--no-mine`, `--no-profile`, and `--all` modify that selection +but are not selectors by themselves. Positional issue identifiers cannot be +combined with filter options. [source,sh] ---- $ lc issue unassign CRY-1234 $ lc issue unassign CRY-1 CRY-2 $ lc issue unassign --output json CRY-1 CRY-2 +$ lc issue unassign --no-profile --assignee alice +$ lc issue unassign --team CRY --project "Roadmap" --status "Human Review" +$ lc issue unassign --all --labels Bug,Feature ---- Text output shows each updated issue and a confirmation. JSON output returns -one object for one issue and an array for multiple issues. +one object for one issue and an array for multiple issues. A filter with no +matches prints `No issues matched.` in text mode and returns `[]` in JSON mode. +The command resolves all matching issues before it starts updates, including +matches beyond the normal 100-record issue-list page limit. Updates remain +independent API calls, so a failed request can leave earlier matches already +unassigned. The existing `--unassigned` issue-list filter is not a selector for +this command because unassigning already-unassigned issues has no effect. ==== Create an issue diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 312e137..7013850 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -909,8 +909,53 @@ defmodule LinearCli.CLI do ], unassign: [ name: "unassign", - about: "Clear the assignee from one or more issues (ISSUE_ID...)", - allow_unknown_args: true + about: "Clear assignees by issue ID or filter", + allow_unknown_args: true, + flags: [ + no_mine: [ + short: "-N", + long: "--no-mine", + help: "Include issues not assigned to the current user" + ], + no_profile: [ + long: "--no-profile", + help: "Ignore the active profile's team/project defaults" + ], + all: [ + long: "--all", + help: "Include completed and cancelled issues" + ] + ], + options: [ + assignee: [ + short: "-a", + long: "--assignee", + help: "Filter by exact assignee name, case-insensitive" + ], + team: [short: "-t", long: "--team", help: "Filter by team key"], + project: [ + short: "-p", + long: "--project", + help: "Filter by project name, URL, ID, or search term" + ], + state: [ + long: "--state", + help: "Filter by workflow state type(s) (comma-separated)", + parser: &parse_states/1 + ], + status: [ + short: "-s", + long: "--status", + help: "Filter by 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 + ] + ] ], take: [ name: "take", diff --git a/app/lib/linear_cli/cli/commands/issues/filter.ex b/app/lib/linear_cli/cli/commands/issues/filter.ex new file mode 100644 index 0000000..7a8329e --- /dev/null +++ b/app/lib/linear_cli/cli/commands/issues/filter.ex @@ -0,0 +1,71 @@ +defmodule LinearCli.CLI.Commands.Issues.Filter do + @moduledoc false + + alias LinearCli.CLI.Projects + alias LinearCli.{Linear, Profiles} + + @doc "Builds the shared issue-list input from CLI flags and options." + def build_input(flags, options, ids \\ [], opts \\ []) do + no_profile = Map.get(flags, :no_profile, false) + team_key = Map.get(options, :team) || unless no_profile, do: Profiles.default_team() + + project_source = + Map.get(options, :project) || unless no_profile, do: Profiles.default_project() + + project_resolution = Keyword.get(opts, :project_resolution, :permissive) + + with {:ok, project_id} <- resolve_project_id(project_source, team_key, project_resolution) do + labels = Map.get(options, :labels) || [] + + {:ok, + %{ + ids: ids, + mine: not Map.get(flags, :no_mine, false), + unassigned: Keyword.get(opts, :unassigned, Map.get(flags, :unassigned, false)), + assignee: Map.get(options, :assignee), + team_key: team_key, + project_id: project_id, + all: Map.get(flags, :all, false), + state: Map.get(options, :state) || [], + status: Map.get(options, :status) || [], + labels: labels, + include_labels: + Keyword.get( + opts, + :include_labels, + Map.get(flags, :include_labels, false) || labels != [] + ), + fetch_all_pages: Keyword.get(opts, :fetch_all_pages, false) + }} + end + end + + defp resolve_project_id(nil, _team_key, _resolution), do: {:ok, nil} + + defp resolve_project_id(search, team_key, resolution) when is_binary(team_key) do + with {:ok, team} <- Linear.find_team(team_key), + {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}) do + resolve_project_match(projects, search, resolution) + end + end + + defp resolve_project_id(search, _team_key, resolution) do + with {:ok, projects} <- Linear.projects() do + resolve_project_match(projects, search, resolution) + end + end + + defp resolve_project_match(projects, search, :permissive) do + case Projects.project_for(projects, search) do + nil -> {:ok, nil} + project -> {:ok, project.id} + end + end + + defp resolve_project_match(projects, search, :strict) do + case Projects.project_for_strict(projects, search) do + nil -> {:error, {:smells_bad, "No project found matching #{search}"}} + project -> {:ok, project.id} + end + end +end diff --git a/app/lib/linear_cli/cli/commands/issues/mutations.ex b/app/lib/linear_cli/cli/commands/issues/mutations.ex index 4dc0c7d..3145b2c 100644 --- a/app/lib/linear_cli/cli/commands/issues/mutations.ex +++ b/app/lib/linear_cli/cli/commands/issues/mutations.ex @@ -5,8 +5,11 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do comment.rb, status.rb, and assign.rb. """ - alias LinearCli.CLI.{Display, Prompt, WhatFor} + alias LinearCli.CLI.Commands.Issues.Filter + alias LinearCli.CLI.Display alias LinearCli.CLI.Issue.{Actions, Identifiers} + alias LinearCli.CLI.Prompt + alias LinearCli.CLI.WhatFor alias LinearCli.Linear @max_concurrent_issue_updates 20 @@ -120,22 +123,21 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do end @doc """ - Clears the assignee from one or more issues. + Clears assignees from explicit issue IDs or from issues selected by filters. Optimus captures the explicit issue IDs in `unknown`, since it has no - variadic positional-argument type. Each issue is resolved before its - assignee is cleared. Updates run concurrently with the same limit and input - order as the other batch issue mutations. + variadic positional-argument type. Filter mode requires an explicit + assignee, team, project, state, status, or label selector. Each matching + issue is resolved before its assignee is cleared. """ @spec issue_unassign(Optimus.ParseResult.t()) :: :ok | {:error, term()} - def issue_unassign(%{unknown: issue_ids, options: options}) do - with :ok <- validate_issue_ids(issue_ids), - {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), - {:ok, updated_issues} <- unassign_issues(issues) do - Display.show(one_or_many(updated_issues), %{output: options.output}) - print_unassign_results(updated_issues, options.output) - :ok + def issue_unassign(%{unknown: issue_ids, options: options} = result) do + flags = Map.get(result, :flags, %{}) + + case unassign_mode(issue_ids, flags, options) do + {:ids, ids} -> issue_unassign_by_ids(ids, options) + :filter -> issue_unassign_by_filter(flags, options) + {:error, reason} -> {:error, reason} end end @@ -173,6 +175,79 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do end end + defp issue_unassign_by_ids(issue_ids, options) do + with {:ok, issues} <- + Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), + {:ok, updated_issues} <- unassign_issues(issues) do + show_unassign_results(updated_issues, options) + end + end + + defp issue_unassign_by_filter(flags, options) do + with {:ok, input} <- + Filter.build_input(flags, options, [], + project_resolution: :strict, + include_labels: false, + fetch_all_pages: true + ), + {:ok, issues} <- Linear.issues(input), + {:ok, updated_issues} <- unassign_filtered_issues(issues) do + show_unassign_results(updated_issues, options) + end + end + + defp unassign_filtered_issues([]), do: {:ok, []} + defp unassign_filtered_issues(issues), do: unassign_issues(issues) + + defp show_unassign_results([], options) do + if Map.get(options, :output) == "json" do + Display.show([], %{output: "json"}) + else + Prompt.ok("No issues matched.") + end + + :ok + end + + defp show_unassign_results(updated_issues, options) do + output = Map.get(options, :output, "text") + Display.show(one_or_many(updated_issues), %{output: output}) + print_unassign_results(updated_issues, output) + :ok + end + + defp unassign_mode(issue_ids, flags, options) do + filters? = explicit_filter_selector?(options) or filter_qualifier?(flags) + + cond do + issue_ids != [] and filters? -> + {:error, {:smells_bad, "Issue IDs cannot be combined with filter options!"}} + + issue_ids != [] -> + {:ids, issue_ids} + + explicit_filter_selector?(options) -> + :filter + + true -> + {:error, {:smells_bad, "Provide issue IDs or at least one filter selector!"}} + end + end + + defp explicit_filter_selector?(options) do + Enum.any?([:assignee, :team, :project], &present_string?(Map.get(options, &1))) or + Enum.any?([:state, :status, :labels], &present_list?(Map.get(options, &1))) + end + + defp filter_qualifier?(flags) do + Map.get(flags, :no_mine, false) or + Map.get(flags, :no_profile, false) or + Map.get(flags, :all, false) + end + + defp present_string?(value), do: is_binary(value) and value != "" + defp present_list?(value), do: is_list(value) and value != [] + defp validate_issue_ids([]), do: {:error, {:smells_bad, "No issue IDs provided!"}} defp validate_issue_ids(_issue_ids), do: :ok diff --git a/app/lib/linear_cli/cli/commands/issues/read.ex b/app/lib/linear_cli/cli/commands/issues/read.ex index f25a8ca..d63d132 100644 --- a/app/lib/linear_cli/cli/commands/issues/read.ex +++ b/app/lib/linear_cli/cli/commands/issues/read.ex @@ -6,10 +6,11 @@ defmodule LinearCli.CLI.Commands.Issues.Read do """ alias LinearCli.Browser + alias LinearCli.CLI.Commands.Issues.Filter alias LinearCli.CLI.Commands.Issues.Graph - alias LinearCli.CLI.{Display, Projects} + alias LinearCli.CLI.Display alias LinearCli.CLI.Issue.Identifiers - alias LinearCli.{Linear, Profiles} + alias LinearCli.Linear @doc """ Ported from commands/issue/list.rb + operations/issue/list.rb. @@ -25,34 +26,13 @@ defmodule LinearCli.CLI.Commands.Issues.Read do `--team`/`--project` passed explicitly always win over the active profile. """ def issue_list(%{flags: flags, options: options, unknown: ids}) do - no_profile = Map.get(flags, :no_profile, false) - team_key = options.team || unless no_profile, do: Profiles.default_team() - - project_source = - 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, &Identifiers.expand_issue_id/1), - mine: !flags.no_mine, - unassigned: flags.unassigned, - team_key: team_key, - project_id: project_id, - all: Map.get(flags, :all, false), - state: Map.get(options, :state) || [], - status: Map.get(options, :status) || [], - labels: label_filter, - include_labels: include_labels - } - + with {:ok, input} <- + Filter.build_input(flags, options, Enum.map(ids, &Identifiers.expand_issue_id/1)) do with {:ok, issues} <- Linear.issues(input) do Display.show(issues, %{ output: options.output, full: flags.full, - labels: include_labels + labels: input.include_labels }) :ok @@ -102,25 +82,4 @@ defmodule LinearCli.CLI.Commands.Issues.Read do end end end - - defp resolve_project_id(nil, _team_key), do: {:ok, nil} - - defp resolve_project_id(search, team_key) when is_binary(team_key) do - with {:ok, team} <- Linear.find_team(team_key), - {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}) do - case Projects.project_for(projects, search) do - nil -> {:ok, nil} - project -> {:ok, project.id} - end - end - end - - defp resolve_project_id(search, _team_key) do - with {:ok, projects} <- Linear.projects() do - case Projects.project_for(projects, search) do - nil -> {:ok, nil} - project -> {:ok, project.id} - end - end - end end diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index 6e4460f..c3059b9 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -11,6 +11,7 @@ defmodule LinearCli.Linear.Issue do argument :ids, {:array, :string}, default: [] argument :mine, :boolean, default: true argument :unassigned, :boolean, default: false + argument :assignee, :string, allow_nil?: true argument :team_key, :string, allow_nil?: true argument :project_id, :string, allow_nil?: true argument :all, :boolean, default: false @@ -18,6 +19,7 @@ defmodule LinearCli.Linear.Issue do argument :status, {:array, :string}, default: [] argument :labels, {:array, :string}, default: [] argument :include_labels, :boolean, default: false + argument :fetch_all_pages, :boolean, default: false manual LinearCli.Linear.Issue.Read.List end @@ -180,7 +182,11 @@ defmodule LinearCli.Linear.Issue.Read.List do if args.ids != [] do find_by_ids(args.ids) else - list_all(build_filter(args), args.include_labels || args.labels != []) + list_all( + build_filter(args), + args.include_labels || args.labels != [], + args.fetch_all_pages + ) end end @@ -243,14 +249,15 @@ defmodule LinearCli.Linear.Issue.Read.List do end end - defp list_all(filter, include_labels) do + defp list_all(filter, include_labels, fetch_all_pages) do document = if include_labels, do: list_document_with_labels(), else: list_document() Paginate.all( document, "issues", fn after_cursor -> %{"filter" => filter, "first" => 50, "after" => after_cursor} end, - &Issue.from_map/1 + &Issue.from_map/1, + if(fetch_all_pages, do: :infinity, else: 100) ) end @@ -299,6 +306,11 @@ defmodule LinearCli.Linear.Issue.Read.List do else: Map.put(filter, "canceledAt", %{"null" => true}) end + defp maybe_put_assignee_filter(filter, %{assignee: assignee}) + when is_binary(assignee) and assignee != "" do + Map.put(filter, "assignee", %{"name" => %{"eqIgnoreCase" => assignee}}) + end + defp maybe_put_assignee_filter(filter, %{unassigned: true}) do Map.put(filter, "assignee", %{"null" => true}) end diff --git a/app/lib/linear_cli/linear/paginate.ex b/app/lib/linear_cli/linear/paginate.ex index c369b68..bdfb84c 100644 --- a/app/lib/linear_cli/linear/paginate.ex +++ b/app/lib/linear_cli/linear/paginate.ex @@ -12,7 +12,7 @@ defmodule LinearCli.Linear.Paginate do @doc """ Fetches pages via `LinearCli.Api.call(document, variables(after_cursor))` until `max` records are collected or the API reports no more pages, decoding each raw - node through `decode_fun`. + node through `decode_fun`. Pass `:infinity` to fetch every page. `field_name` is the top-level response key (e.g. `"teams"`) holding `edges`/`pageInfo`. `variables_fun` receives the current `after` cursor @@ -28,10 +28,16 @@ defmodule LinearCli.Linear.Paginate do fetch_connection(data, field_name) do acc = acc ++ Enum.map(edges, &decode_fun.(&1["node"])) - if length(acc) >= max or !page_info["hasNextPage"] do - {:ok, Enum.take(acc, max)} + if reached_limit?(acc, max) or !page_info["hasNextPage"] do + {:ok, take_max(acc, max)} else - do_all(document, field_name, variables_fun, decode_fun, page_info["endCursor"], max, acc) + next_cursor = page_info["endCursor"] + + if next_cursor == after_cursor do + {:error, {:non_advancing_cursor, next_cursor}} + else + do_all(document, field_name, variables_fun, decode_fun, next_cursor, max, acc) + end end else {:error, {:http_error, status, _body}} -> {:error, {:http_error, status}} @@ -39,6 +45,12 @@ defmodule LinearCli.Linear.Paginate do end end + defp reached_limit?(_acc, :infinity), do: false + defp reached_limit?(acc, max), do: length(acc) >= max + + defp take_max(acc, :infinity), do: acc + defp take_max(acc, max), do: Enum.take(acc, max) + # Safely extracts the named connection from the response data. Returns # {:error, {:unexpected_response, ...}} instead of crashing with KeyError # when the field is absent or not the expected connection shape. diff --git a/app/test/linear_cli/cli/commands/issues/mutations_test.exs b/app/test/linear_cli/cli/commands/issues/mutations_test.exs index 1c292fa..6e64422 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -126,7 +126,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert Enum.map(decoded, & &1["identifier"]) == ["CRY-1", "CRY-2"] end - test "with no issue IDs, exits 22" do + test "with no issue IDs or filter selector, exits 22" do test_pid = self() halt = fn code -> send(test_pid, {:halted, code}) end @@ -136,7 +136,312 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end) assert_received {:halted, 22} - assert output =~ "No issue IDs provided!" + assert output =~ "Provide issue IDs or at least one filter selector!" + end + + test "filters by assignee and clears every matching issue" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issues(filter:") -> + send(test_pid, {:issue_filter, decoded["variables"]}) + Req.Test.json(conn, issues_response([issue_map(%{"assignee" => me_map()})])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:unassign_input, decoded["variables"]["input"]}) + Req.Test.json(conn, issue_updated(%{"assignee" => nil})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--no-profile", + "--assignee", + "Ada" + ]) + end) + + assert_received {:issue_filter, %{"filter" => filter, "first" => 50, "after" => nil}} + assert filter["assignee"] == %{"name" => %{"eqIgnoreCase" => "Ada"}} + refute get_in(filter, ["assignee", "isMe"]) + assert_received {:unassign_input, %{"assigneeId" => nil}} + assert output =~ "CRY-1 unassigned" + end + + test "fetches all filtered pages before unassigning more than 100 matches" do + test_pid = self() + + page_issues = fn first, last -> + Enum.map(first..last, fn number -> + issue_map(%{"id" => "i#{number}", "identifier" => "CRY-#{number}"}) + end) + end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issues(filter:") -> + cursor = decoded["variables"]["after"] + send(test_pid, {:page_requested, cursor}) + + page = + case cursor do + nil -> issues_response_page(page_issues.(1, 50), true, "c1") + "c1" -> issues_response_page(page_issues.(51, 100), true, "c2") + "c2" -> issues_response_page(page_issues.(101, 120), false, "c3") + end + + Req.Test.json(conn, page) + + String.contains?(query, "issueUpdate") -> + identifier = decoded["variables"]["id"] + send(test_pid, {:updated, identifier}) + Req.Test.json(conn, issue_updated(%{"identifier" => identifier, "assignee" => nil})) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--output", + "json", + "--no-profile", + "--team", + "ENG" + ]) + end) + + assert_received {:page_requested, nil} + assert_received {:page_requested, "c1"} + assert_received {:page_requested, "c2"} + + assert {:ok, updated} = Jason.decode(output) + assert length(updated) == 120 + assert List.first(updated)["identifier"] == "CRY-1" + assert List.last(updated)["identifier"] == "CRY-120" + + updated_ids = + Enum.reduce(1..120, [], fn _number, acc -> + receive do + {:updated, identifier} -> [identifier | acc] + after + 1_000 -> flunk("expected all 120 issue updates") + end + end) + + assert length(updated_ids) == 120 + end + + test "shares team, state, status, and label filters with issue list" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + if String.contains?(query, "issues(filter:") do + send(test_pid, {:issue_filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([])) + else + raise "unassign must not mutate an empty filtered result" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--no-profile", + "--no-mine", + "--team", + "ENG", + "--state", + "started", + "--status", + "Human Review", + "--labels", + "Bug,Feature" + ]) + end) + + assert_received {:issue_filter, filter} + assert filter["team"] == %{"key" => %{"eq" => "ENG"}} + assert filter["state"]["type"] == %{"in" => ["started"]} + assert filter["state"]["name"] == %{"eqIgnoreCase" => "Human Review"} + + assert filter["labels"] == %{ + "some" => %{ + "or" => [ + %{"name" => %{"eqIgnoreCase" => "Bug"}}, + %{"name" => %{"eqIgnoreCase" => "Feature"}} + ] + } + } + end + + test "reports no matches without sending an update" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + query = Jason.decode!(body)["query"] + + if String.contains?(query, "issues(filter:") do + Req.Test.json(conn, issues_response([])) + else + raise "no update is expected when no issues match" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--no-profile", + "--team", + "ENG" + ]) + end) + + assert output =~ "No issues matched." + end + + test "returns an empty JSON array for no matches" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + query = Jason.decode!(body)["query"] + + if String.contains?(query, "issues(filter:") do + Req.Test.json(conn, issues_response([])) + else + raise "no update is expected when no issues match" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--output", + "json", + "--no-profile", + "--team", + "ENG" + ]) + end) + + assert {:ok, []} = Jason.decode(output) + end + + test "rejects issue IDs combined with filter options before a GraphQL call" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn _conn -> + raise "the conflicting invocation must not make a GraphQL call" + end) + + output = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "unassign", "--team", "ENG", "--no-profile", "CRY-1"], + halt, + stderr: stderr + ) + end) + + assert_received {:halted, 22} + assert output =~ "Issue IDs cannot be combined with filter options!" + end + + test "fails safely when the requested project does not resolve" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + query = Jason.decode!(body)["query"] + + if String.contains?(query, "projects(first: $first") do + Req.Test.json(conn, all_projects([])) + else + raise "a missing project must stop before the issue query" + end + end) + + output = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "unassign", "--no-profile", "--project", "Missing Project"], + halt, + stderr: stderr + ) + end) + + assert_received {:halted, 22} + assert output =~ "No project found matching Missing Project" + end + + test "fetches every page before starting updates" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issues(filter:") -> + case decoded["variables"]["after"] do + nil -> Req.Test.json(conn, issues_response_page([issue_map()], true, "c1")) + "c1" -> Plug.Conn.resp(conn, 502, "upstream unavailable") + end + + String.contains?(query, "issueUpdate") -> + raise "no update is allowed after a later-page read error" + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + ["issue", "unassign", "--no-profile", "--team", "ENG"], + halt, + stderr: stderr + ) + end) + + assert_received {:halted, 88} + assert output =~ "Cannot Continue" end test "rejects unrecognized options before making a GraphQL call" do diff --git a/app/test/linear_cli/cli/profile_defaults_test.exs b/app/test/linear_cli/cli/profile_defaults_test.exs index 089a34a..a22bf3e 100644 --- a/app/test/linear_cli/cli/profile_defaults_test.exs +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -444,6 +444,66 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do assert_received {:id, "CRY-42"} assert output =~ "CRY-42 unassigned" end + + test "filter mode applies the active profile's team and project defaults" do + {:ok, _} = Profiles.create("manhattan", team: "CRY", project: "Manhattan Rollout") + :ok = Profiles.activate("manhattan") + + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map("CRY")}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([project_map("p1", "Manhattan Rollout")])) + + String.contains?(query, "issues(filter") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map(%{"assignee" => me_map()})])) + + String.contains?(query, "issueUpdate") -> + Req.Test.json( + conn, + %{ + "data" => %{ + "issueUpdate" => %{"issue" => issue_map(%{"assignee" => nil})} + } + } + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{ + unknown: [], + flags: %{no_mine: false, no_profile: false, all: false}, + options: %{ + assignee: "Ada", + team: nil, + project: nil, + state: [], + status: [], + labels: [], + output: "text" + } + } + + output = capture_io(fn -> assert :ok = Mutations.issue_unassign(result) end) + + assert output =~ "CRY-1 unassigned" + assert_received {:filter, filter} + assert filter["assignee"] == %{"name" => %{"eqIgnoreCase" => "Ada"}} + assert filter["team"] == %{"key" => %{"eq" => "CRY"}} + assert filter["project"] == %{"id" => %{"eq" => "p1"}} + end end describe "Development.issue_develop/2, issue_pr/2, issue_take/2 resolve bare issue numbers via the active profile" do diff --git a/app/test/linear_cli/linear/paginate_test.exs b/app/test/linear_cli/linear/paginate_test.exs new file mode 100644 index 0000000..b5dcb5d --- /dev/null +++ b/app/test/linear_cli/linear/paginate_test.exs @@ -0,0 +1,103 @@ +defmodule LinearCli.Linear.PaginateTest do + use ExUnit.Case, async: true + + alias LinearCli.Linear.Paginate + + defp response(ids, has_next_page, end_cursor) do + %{ + "data" => %{ + "issues" => %{ + "edges" => Enum.map(ids, &%{"node" => %{"id" => &1}, "cursor" => "row-#{&1}"}), + "pageInfo" => %{"hasNextPage" => has_next_page, "endCursor" => end_cursor} + } + } + } + end + + defp variables_fun(after_cursor), do: %{"after" => after_cursor} + + test "uses the default 100-record limit" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + cursor = Jason.decode!(body)["variables"]["after"] + send(test_pid, {:cursor, cursor}) + + response = + case cursor do + nil -> response(1..50, true, "c1") + "c1" -> response(51..120, true, "c2") + end + + Req.Test.json(conn, response) + end) + + assert {:ok, values} = Paginate.all("query", "issues", &variables_fun/1, & &1["id"]) + assert length(values) == 100 + assert List.first(values) == 1 + assert List.last(values) == 100 + assert_receive {:cursor, nil} + assert_receive {:cursor, "c1"} + refute_receive {:cursor, "c2"} + end + + test "fetches every page when max is infinity" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + cursor = Jason.decode!(body)["variables"]["after"] + send(test_pid, {:cursor, cursor}) + + response = + case cursor do + nil -> response([1], true, "c1") + "c1" -> response([2], true, "c2") + "c2" -> response([3], false, "c3") + end + + Req.Test.json(conn, response) + end) + + assert {:ok, [1, 2, 3]} = + Paginate.all("query", "issues", &variables_fun/1, & &1["id"], :infinity) + + assert_receive {:cursor, nil} + assert_receive {:cursor, "c1"} + assert_receive {:cursor, "c2"} + end + + test "returns a later-page error before completing an unbounded read" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + cursor = Jason.decode!(body)["variables"]["after"] + + case cursor do + nil -> Req.Test.json(conn, response([1], true, "c1")) + "c1" -> Plug.Conn.resp(conn, 502, "upstream unavailable") + end + end) + + assert {:error, {:http_error, 502}} = + Paginate.all("query", "issues", &variables_fun/1, & &1["id"], :infinity) + end + + test "returns an error when the API repeats a continuation cursor" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + cursor = Jason.decode!(body)["variables"]["after"] + + response = + case cursor do + nil -> response([1], true, "c1") + "c1" -> response([2], true, "c1") + end + + Req.Test.json(conn, response) + end) + + assert {:error, {:non_advancing_cursor, "c1"}} = + Paginate.all("query", "issues", &variables_fun/1, & &1["id"], :infinity) + end +end diff --git a/app/test/support/issue_commands_helpers.ex b/app/test/support/issue_commands_helpers.ex index 5b73c6b..95d25ce 100644 --- a/app/test/support/issue_commands_helpers.ex +++ b/app/test/support/issue_commands_helpers.ex @@ -141,6 +141,17 @@ defmodule LinearCli.CLI.IssueCommandsHelpers do } end + def issues_response_page(issues, has_next_page, end_cursor) do + %{ + "data" => %{ + "issues" => %{ + "edges" => Enum.map(issues, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => %{"hasNextPage" => has_next_page, "endCursor" => end_cursor} + } + } + } + end + def tmp_path(prefix) do Path.join( System.tmp_dir!(), diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 22d4392..b63488c 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -365,7 +365,9 @@ manual-implementation module, and the Linear GraphQL operation it calls. (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) + invariant; `assignee` filters by exact case-insensitive assignee name; and + `fetch_all_pages` opts into an unbounded cursor walk for filter-only batch + operations; or `issue(id:)` per id (full, fanned concurrently) | `Issue` | `create_issue` @@ -532,19 +534,34 @@ and returns `{:ok, %{root:, nodes:, edges:}}` or `{:error, {identifier, reason}} Capped at 100 nodes; deterministic output (nodes sorted by identifier, edges by source then target). +=== `LinearCli.CLI.Commands.Issues.Filter` + +`app/lib/linear_cli/cli/commands/issues/filter.ex` — shared CLI selector +construction for `issue list` and filter-only `issue unassign`. + +`build_input/4` applies the active profile's team and project defaults unless +`--no-profile` is set, resolves a project in the selected team (or workspace), +and returns the common issue-list arguments for the domain. Filter-only +unassignment requests strict project matching and enables `fetch_all_pages`; +ordinary issue listing keeps the existing permissive project resolution and +100-record cap. + === `LinearCli.Linear.Paginate` `app/lib/linear_cli/linear/paginate.ex` — a standalone helper module. Provides `all/5`: fetches cursor-paginated GraphQL connections (`edges { node { ... } cursor } pageInfo { hasNextPage endCursor }`) -until `max` records are collected or the API signals no more pages. +until `max` records are collected or the API signals no more pages. Passing +`:infinity` fetches every advertised page, and a repeated continuation cursor +returns an error instead of looping forever. Used by: * `Team.Read.All` — `teams(first:, after:)` paginated query * `Project.Read.All` — `projects(first:, after:)` paginated query * `Issue.Read.List` — `issues(filter:, first:, after:)` paginated list - (the find-by-ids path fans out individual `issue(id:)` calls instead) + (the find-by-ids path fans out individual `issue(id:)` calls instead; the + filter-only unassign path opts into `:infinity`) == Maintenance contract From 8902fd1639d0315b41af565f42920cb8dfd9948d Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 26 Sep 2026 10:02:22 -0400 Subject: [PATCH 2/5] fix(issue): guard filter-only unassignment --- AGENTS.md | 1 + Readme.adoc | 12 +- app/lib/linear_cli/cli.ex | 11 +- .../linear_cli/cli/commands/issues/filter.ex | 194 +++++++++++--- .../cli/commands/issues/mutations.ex | 55 +++- app/lib/linear_cli/linear/issue.ex | 6 + app/lib/linear_cli/linear/user.ex | 4 +- .../cli/commands/issues/mutations_test.exs | 242 +++++++++++++++++- .../linear_cli/cli/profile_defaults_test.exs | 18 +- documents/ash-domain-erd.adoc | 6 +- 10 files changed, 500 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fc9073a..30e832d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,6 +96,7 @@ LinearCli.CLI — Entry point; Optimus argument parsing a CLI.Commands.Teams — team list/favorite/unfavorite subcommands CLI.Commands.Projects — project list/favorite/unfavorite/update subcommands CLI.Commands.Issues.Read — issue read/list/show subcommands + CLI.Commands.Issues.Filter — shared issue filter resolution CLI.Commands.Issues.Create — issue create subcommand CLI.Commands.Issues.Development — issue branch/PR subcommands CLI.Commands.Issues.Mutations — issue update/status/assign/comment subcommands diff --git a/Readme.adoc b/Readme.adoc index 1e91034..df3719d 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -300,14 +300,20 @@ combined with filter options. $ lc issue unassign CRY-1234 $ lc issue unassign CRY-1 CRY-2 $ lc issue unassign --output json CRY-1 CRY-2 -$ lc issue unassign --no-profile --assignee alice -$ lc issue unassign --team CRY --project "Roadmap" --status "Human Review" -$ lc issue unassign --all --labels Bug,Feature +$ lc issue unassign --no-profile --assignee "Alice Smith" --yes +$ lc issue unassign --team CRY --project "Roadmap" --status "Human Review" --dry-run +$ lc issue unassign --no-mine --all --labels Bug,Feature --yes ---- Text output shows each updated issue and a confirmation. JSON output returns one object for one issue and an array for multiple issues. A filter with no matches prints `No issues matched.` in text mode and returns `[]` in JSON mode. +Filter mode selects only your own issues by default; use `--no-mine` to include +issues assigned to other users, or `--assignee` to select a named user. Exact +project and assignee matches are used without a prompt. Partial matches prompt +you to choose a project or assignee. Before any filtered mutation, the command +asks for confirmation; `--yes` skips that prompt and `--dry-run` prints the +matching issues without changing them. The command resolves all matching issues before it starts updates, including matches beyond the normal 100-record issue-list page limit. Updates remain independent API calls, so a failed request can leave earlier matches already diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 7013850..b45bbff 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -924,13 +924,22 @@ defmodule LinearCli.CLI do all: [ long: "--all", help: "Include completed and cancelled issues" + ], + dry_run: [ + long: "--dry-run", + help: "Preview matching issues without unassigning them" + ], + yes: [ + short: "-y", + long: "--yes", + help: "Skip the confirmation prompt" ] ], options: [ assignee: [ short: "-a", long: "--assignee", - help: "Filter by exact assignee name, case-insensitive" + help: "Filter by assignee name (exact or partial match)" ], team: [short: "-t", long: "--team", help: "Filter by team key"], project: [ diff --git a/app/lib/linear_cli/cli/commands/issues/filter.ex b/app/lib/linear_cli/cli/commands/issues/filter.ex index 7a8329e..40d6e2a 100644 --- a/app/lib/linear_cli/cli/commands/issues/filter.ex +++ b/app/lib/linear_cli/cli/commands/issues/filter.ex @@ -1,45 +1,57 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do @moduledoc false - alias LinearCli.CLI.Projects + alias LinearCli.CLI.{Projects, Prompt} alias LinearCli.{Linear, Profiles} @doc "Builds the shared issue-list input from CLI flags and options." def build_input(flags, options, ids \\ [], opts \\ []) do + {team_key, project_source} = filter_sources(flags, options) + project_resolution = Keyword.get(opts, :project_resolution, :permissive) + + with {:ok, project_id} <- resolve_project_id(project_source, team_key, project_resolution), + {:ok, assignee_id} <- resolve_assignee_id(Map.get(options, :assignee), team_key, opts) do + {:ok, build_issue_input(flags, options, ids, opts, team_key, project_id, assignee_id)} + end + end + + defp filter_sources(flags, options) do no_profile = Map.get(flags, :no_profile, false) - team_key = Map.get(options, :team) || unless no_profile, do: Profiles.default_team() + team = Map.get(options, :team) || profile_default(no_profile, &Profiles.default_team/0) - project_source = - Map.get(options, :project) || unless no_profile, do: Profiles.default_project() + project = + Map.get(options, :project) || profile_default(no_profile, &Profiles.default_project/0) - project_resolution = Keyword.get(opts, :project_resolution, :permissive) + {team, project} + end - with {:ok, project_id} <- resolve_project_id(project_source, team_key, project_resolution) do - labels = Map.get(options, :labels) || [] - - {:ok, - %{ - ids: ids, - mine: not Map.get(flags, :no_mine, false), - unassigned: Keyword.get(opts, :unassigned, Map.get(flags, :unassigned, false)), - assignee: Map.get(options, :assignee), - team_key: team_key, - project_id: project_id, - all: Map.get(flags, :all, false), - state: Map.get(options, :state) || [], - status: Map.get(options, :status) || [], - labels: labels, - include_labels: - Keyword.get( - opts, - :include_labels, - Map.get(flags, :include_labels, false) || labels != [] - ), - fetch_all_pages: Keyword.get(opts, :fetch_all_pages, false) - }} - end + defp profile_default(true, _default), do: nil + defp profile_default(false, default), do: default.() + + defp build_issue_input(flags, options, ids, opts, team_key, project_id, assignee_id) do + labels = Map.get(options, :labels) || [] + + %{ + ids: ids, + mine: not Map.get(flags, :no_mine, false), + unassigned: Keyword.get(opts, :unassigned, Map.get(flags, :unassigned, false)), + assignee: assignee_for_input(options, assignee_id), + assignee_id: assignee_id, + team_key: team_key, + project_id: project_id, + all: Map.get(flags, :all, false), + state: Map.get(options, :state) || [], + status: Map.get(options, :status) || [], + labels: labels, + include_labels: + Keyword.get(opts, :include_labels, Map.get(flags, :include_labels, false) || labels != []), + fetch_all_pages: Keyword.get(opts, :fetch_all_pages, false) + } end + defp assignee_for_input(options, nil), do: Map.get(options, :assignee) + defp assignee_for_input(_options, _assignee_id), do: nil + defp resolve_project_id(nil, _team_key, _resolution), do: {:ok, nil} defp resolve_project_id(search, team_key, resolution) when is_binary(team_key) do @@ -63,9 +75,127 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do end defp resolve_project_match(projects, search, :strict) do - case Projects.project_for_strict(projects, search) do - nil -> {:error, {:smells_bad, "No project found matching #{search}"}} - project -> {:ok, project.id} + case Projects.project_scores(projects, search) do + [] -> + {:error, {:smells_bad, "No project found matching #{search}"}} + + possibles -> + case Enum.find(possibles, &(LinearCli.Linear.Project.match_score?(&1, search) == 100)) do + nil -> + case Projects.project_for(projects, search) do + nil -> {:error, {:smells_bad, "No project found matching #{search}"}} + project -> {:ok, project.id} + end + + project -> + {:ok, project.id} + end + end + end + + defp resolve_assignee_id(nil, _team_key, _opts), do: {:ok, nil} + + defp resolve_assignee_id(assignee, _team_key, _opts) + when not is_binary(assignee) or assignee == "", + do: {:ok, nil} + + defp resolve_assignee_id(assignee, team_key, opts) do + if Keyword.get(opts, :resolve_assignee, false) do + with {:ok, members} <- assignee_members(team_key), + {:ok, member} <- resolve_assignee_member(members, assignee) do + {:ok, member.id} + end + else + {:ok, nil} + end + end + + defp assignee_members(team_key) when is_binary(team_key) do + with {:ok, team} <- Linear.find_team(team_key), do: Linear.team_members(team.id) + end + + defp assignee_members(nil) do + with {:ok, teams} <- Linear.teams() do + teams + |> Enum.reduce_while({:ok, %{}}, &collect_team_members/2) + |> members_from_result() + end + end + + defp collect_team_members(team, {:ok, members_by_id}) do + case Linear.team_members(team.id) do + {:ok, members} -> + members_by_id = + Enum.reduce(members, members_by_id, fn member, acc -> + Map.put(acc, member.id, member) + end) + + {:cont, {:ok, members_by_id}} + + {:error, reason} -> + {:halt, {:error, reason}} + end + end + + defp members_from_result({:ok, members_by_id}), do: {:ok, Map.values(members_by_id)} + defp members_from_result(error), do: error + + defp resolve_assignee_member(members, search) do + normalized_search = String.downcase(search) + exact = Enum.filter(members, &assignee_exact_match?(&1, normalized_search)) + + case exact do + [member] -> + {:ok, member} + + [_ | _] -> + {:error, ambiguous_assignee_error(exact, search)} + + [] -> + partial = Enum.filter(members, &assignee_partial_match?(&1, normalized_search)) + + case partial do + [] -> {:error, unknown_assignee_error(members, search)} + matches -> {:ok, Prompt.select("Assignee:", assignee_choices(matches))} + end + end + end + + defp assignee_exact_match?(member, search) do + Enum.any?(assignee_names(member), &(String.downcase(&1) == search)) + end + + defp assignee_partial_match?(member, search) do + Enum.any?(assignee_names(member), &String.starts_with?(String.downcase(&1), search)) + end + + defp assignee_names(member) do + [Map.get(member, :name), Map.get(member, :display_name)] + |> Enum.filter(&(is_binary(&1) and &1 != "")) + |> Enum.uniq() + end + + defp assignee_choices(members) do + members + |> Enum.sort_by(&assignee_label/1) + |> Enum.map(&{assignee_label(&1), &1}) + end + + defp assignee_label(member) do + case assignee_names(member) do + [name, display_name] when name != display_name -> "#{name} (#{display_name})" + [name | _] -> name + [] -> member.id end end + + defp ambiguous_assignee_error(members, search) do + matches = Enum.map_join(assignee_choices(members), ", ", &elem(&1, 0)) + {:smells_bad, "Ambiguous assignee #{inspect(search)}: matches #{matches}"} + end + + defp unknown_assignee_error(members, search) do + available = Enum.map_join(assignee_choices(members), ", ", &elem(&1, 0)) + {:smells_bad, "Unknown assignee #{inspect(search)}. Available: #{available}"} + end end diff --git a/app/lib/linear_cli/cli/commands/issues/mutations.ex b/app/lib/linear_cli/cli/commands/issues/mutations.ex index 3145b2c..c218e9b 100644 --- a/app/lib/linear_cli/cli/commands/issues/mutations.ex +++ b/app/lib/linear_cli/cli/commands/issues/mutations.ex @@ -188,16 +188,58 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do Filter.build_input(flags, options, [], project_resolution: :strict, include_labels: false, - fetch_all_pages: true + fetch_all_pages: true, + resolve_assignee: true ), - {:ok, issues} <- Linear.issues(input), - {:ok, updated_issues} <- unassign_filtered_issues(issues) do + {:ok, issues} <- Linear.issues(input) do + unassign_filtered_issues(issues, flags, options) + end + end + + defp unassign_filtered_issues([], _flags, options), do: show_unassign_results([], options) + + defp unassign_filtered_issues(issues, %{dry_run: true}, options) do + show_unassign_dry_run(issues, options) + end + + defp unassign_filtered_issues(issues, %{yes: true}, options) do + unassign_and_show(issues, options) + end + + defp unassign_filtered_issues(issues, _flags, options) do + if Prompt.yes?("Unassign #{length(issues)} issue(s)?") do + unassign_and_show(issues, options) + else + cancel_unassign(options) + end + end + + defp unassign_and_show(issues, options) do + with {:ok, updated_issues} <- unassign_issues(issues) do show_unassign_results(updated_issues, options) end end - defp unassign_filtered_issues([]), do: {:ok, []} - defp unassign_filtered_issues(issues), do: unassign_issues(issues) + defp show_unassign_dry_run(issues, options) do + output = Map.get(options, :output, "text") + Display.show(one_or_many(issues), %{output: output}) + + if output != "json" do + Prompt.ok("Would unassign #{length(issues)} issue(s)") + end + + :ok + end + + defp cancel_unassign(options) do + if Map.get(options, :output, "text") == "json" do + Display.show([], %{output: "json"}) + else + Prompt.warn("Unassign cancelled") + end + + :ok + end defp show_unassign_results([], options) do if Map.get(options, :output) == "json" do @@ -220,6 +262,9 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do filters? = explicit_filter_selector?(options) or filter_qualifier?(flags) cond do + issue_ids != [] and Map.get(flags, :dry_run, false) -> + {:error, {:smells_bad, "--dry-run is only available in filter mode!"}} + issue_ids != [] and filters? -> {:error, {:smells_bad, "Issue IDs cannot be combined with filter options!"}} diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index c3059b9..85d1fa3 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -12,6 +12,7 @@ defmodule LinearCli.Linear.Issue do argument :mine, :boolean, default: true argument :unassigned, :boolean, default: false argument :assignee, :string, allow_nil?: true + argument :assignee_id, :string, allow_nil?: true argument :team_key, :string, allow_nil?: true argument :project_id, :string, allow_nil?: true argument :all, :boolean, default: false @@ -306,6 +307,11 @@ defmodule LinearCli.Linear.Issue.Read.List do else: Map.put(filter, "canceledAt", %{"null" => true}) end + defp maybe_put_assignee_filter(filter, %{assignee_id: assignee_id}) + when is_binary(assignee_id) and assignee_id != "" do + Map.put(filter, "assignee", %{"id" => %{"eq" => assignee_id}}) + end + defp maybe_put_assignee_filter(filter, %{assignee: assignee}) when is_binary(assignee) and assignee != "" do Map.put(filter, "assignee", %{"name" => %{"eqIgnoreCase" => assignee}}) diff --git a/app/lib/linear_cli/linear/user.ex b/app/lib/linear_cli/linear/user.ex index 69b68bc..a41b9dd 100644 --- a/app/lib/linear_cli/linear/user.ex +++ b/app/lib/linear_cli/linear/user.ex @@ -20,11 +20,12 @@ defmodule LinearCli.Linear.User do attributes do attribute :id, :string, primary_key?: true, allow_nil?: false, public?: true attribute :name, :string, public?: true + attribute :display_name, :string, public?: true attribute :email, :string, public?: true attribute :teams, {:array, :term}, public?: true, default: [] end - @base_fields "id name email" + @base_fields "id name displayName email" @doc "GraphQL field selection for a user's own fields (no nested teams)." def base_fields, do: @base_fields @@ -39,6 +40,7 @@ defmodule LinearCli.Linear.User do struct!(__MODULE__, id: map["id"], name: map["name"], + display_name: map["displayName"], email: map["email"], teams: Enum.map(get_in(map, ["teams", "nodes"]) || [], &LinearCli.Linear.Team.from_map/1) ) diff --git a/app/test/linear_cli/cli/commands/issues/mutations_test.exs b/app/test/linear_cli/cli/commands/issues/mutations_test.exs index 6e64422..84fd5ec 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -10,6 +10,10 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do %{"id" => id, "name" => name, "position" => position, "type" => type, "description" => nil} end + defp assignee_members_response(members) do + %{"data" => %{"team" => %{"members" => %{"nodes" => members}}}} + end + describe "issue unassign edge cases" do test "sends a null assignee and confirms each issue in text output" do test_pid = self() @@ -148,6 +152,23 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do query = decoded["query"] cond do + String.contains?(query, "members(first: 50)") -> + Req.Test.json( + conn, + %{ + "data" => %{ + "team" => %{ + "members" => %{ + "nodes" => [%{"id" => "u1", "name" => "Ada", "email" => "ada@example.com"}] + } + } + } + } + ) + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + String.contains?(query, "issues(filter:") -> send(test_pid, {:issue_filter, decoded["variables"]}) Req.Test.json(conn, issues_response([issue_map(%{"assignee" => me_map()})])) @@ -168,15 +189,19 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do "issue", "unassign", "--no-profile", + "--team", + "ENG", "--assignee", - "Ada" + "Ada", + "--yes" ]) end) assert_received {:issue_filter, %{"filter" => filter, "first" => 50, "after" => nil}} - assert filter["assignee"] == %{"name" => %{"eqIgnoreCase" => "Ada"}} + assert filter["assignee"] == %{"id" => %{"eq" => "u1"}} refute get_in(filter, ["assignee", "isMe"]) assert_received {:unassign_input, %{"assigneeId" => nil}} + refute output =~ "Unassign 1 issue(s)?" assert output =~ "CRY-1 unassigned" end @@ -228,7 +253,8 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do "json", "--no-profile", "--team", - "ENG" + "ENG", + "--yes" ]) end) @@ -253,6 +279,127 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert length(updated_ids) == 120 end + test "confirms the filtered batch and cancels without mutating" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = decoded = Jason.decode!(body) + + cond do + String.contains?(query, "issues(filter:") -> + Req.Test.json(conn, issues_response([issue_map()])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :mutated) + raise "a declined filtered batch must not mutate" + + true -> + raise "no stub matched query: #{inspect(decoded)}" + end + end) + + output = + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--no-profile", + "--team", + "ENG", + "--state", + "started" + ]) + end) + + refute_received :mutated + assert output =~ "Unassign 1 issue(s)?" + assert output =~ "Unassign cancelled" + end + + test "--dry-run lists filtered matches without mutation" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issues(filter:") -> + Req.Test.json(conn, issues_response([issue_map()])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :mutated) + raise "--dry-run must not mutate" + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--no-profile", + "--team", + "ENG", + "--state", + "started", + "--dry-run" + ]) + end) + + refute_received :mutated + assert output =~ "CRY-1" + assert output =~ "Would unassign 1 issue(s)" + end + + test "--dry-run returns the selected issues as JSON without mutation" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issues(filter:") -> + Req.Test.json(conn, issues_response([issue_map()])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :mutated) + raise "--dry-run must not mutate" + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--output", + "json", + "--no-profile", + "--team", + "ENG", + "--state", + "started", + "--dry-run" + ]) + end) + + refute_received :mutated + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + end + test "shares team, state, status, and label filters with issue list" do test_pid = self() @@ -407,6 +554,95 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert output =~ "No project found matching Missing Project" end + test "prompts for a partial project match before filtering" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, team_projects([project_map("p1", "Roadmap Q4")])) + + String.contains?(query, "issues(filter:") -> + Req.Test.json(conn, issues_response([])) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io([input: "1\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--no-profile", + "--team", + "ENG", + "--project", + "Roadmap", + "--dry-run" + ]) + end) + + assert output =~ "Project:" + assert output =~ "No issues matched." + end + + test "prompts for a partial assignee match and filters by the selected ID" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = decoded = Jason.decode!(body) + + cond do + String.contains?(query, "members(first: 50)") -> + Req.Test.json( + conn, + assignee_members_response([ + %{"id" => "u1", "name" => "Alice Smith", "displayName" => "alice"}, + %{"id" => "u2", "name" => "Alina Jones", "displayName" => "alina"} + ]) + ) + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issues(filter:") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([])) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io([input: "1\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--no-profile", + "--team", + "ENG", + "--assignee", + "Ali", + "--dry-run" + ]) + end) + + assert output =~ "Assignee:" + assert output =~ "No issues matched." + assert_received {:filter, filter} + assert filter["assignee"] == %{"id" => %{"eq" => "u1"}} + end + test "fetches every page before starting updates" do test_pid = self() halt = fn code -> send(test_pid, {:halted, code}) end diff --git a/app/test/linear_cli/cli/profile_defaults_test.exs b/app/test/linear_cli/cli/profile_defaults_test.exs index a22bf3e..d004645 100644 --- a/app/test/linear_cli/cli/profile_defaults_test.exs +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -457,6 +457,20 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do query = decoded["query"] cond do + String.contains?(query, "members(first: 50)") -> + Req.Test.json( + conn, + %{ + "data" => %{ + "team" => %{ + "members" => %{ + "nodes" => [%{"id" => "u1", "name" => "Ada", "email" => "ada@example.com"}] + } + } + } + } + ) + String.contains?(query, "team(id: $id)") -> Req.Test.json(conn, %{"data" => %{"team" => team_map("CRY")}}) @@ -484,7 +498,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do result = %{ unknown: [], - flags: %{no_mine: false, no_profile: false, all: false}, + flags: %{no_mine: false, no_profile: false, all: false, yes: true, dry_run: false}, options: %{ assignee: "Ada", team: nil, @@ -500,7 +514,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do assert output =~ "CRY-1 unassigned" assert_received {:filter, filter} - assert filter["assignee"] == %{"name" => %{"eqIgnoreCase" => "Ada"}} + assert filter["assignee"] == %{"id" => %{"eq" => "u1"}} assert filter["team"] == %{"key" => %{"eq" => "CRY"}} assert filter["project"] == %{"id" => %{"eq" => "p1"}} end diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index b63488c..610ce94 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -44,6 +44,7 @@ erDiagram User { string id PK string name + string display_name string email Team[] teams } @@ -149,7 +150,7 @@ Nine resources are registered in `LinearCli.Linear` | `LinearCli.Linear.User` | `id` (`:string`) -| `name`, `email`, `teams` (`{:array, :term}`) +| `name`, `display_name`, `email`, `teams` (`{:array, :term}`) | `LinearCli.Linear.Team` | `id` (`:string`) @@ -365,7 +366,8 @@ manual-implementation module, and the Linear GraphQL operation it calls. (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; `assignee` filters by exact case-insensitive assignee name; and + invariant; `assignee` filters by exact case-insensitive assignee name when no + resolved ID is supplied, while `assignee_id` filters by exact user ID; and `fetch_all_pages` opts into an unbounded cursor walk for filter-only batch operations; or `issue(id:)` per id (full, fanned concurrently) From a715d5f16e3dc2d34191313486035d4f1f65b59f Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 26 Sep 2026 11:08:14 -0400 Subject: [PATCH 3/5] fix(issue): cap filtered unassignment batches --- Readme.adoc | 9 +++-- .../cli/commands/issues/mutations.ex | 22 ++++++++-- app/lib/linear_cli/cli/prompt.ex | 20 +++++----- app/lib/linear_cli/linear.ex | 5 +++ app/lib/linear_cli/linear/issue.ex | 24 +++++++++++ app/lib/linear_cli/linear/paginate.ex | 19 +++++++++ .../cli/commands/issues/mutations_test.exs | 40 ++++++++----------- app/test/linear_cli/cli/prompt_test.exs | 5 +++ app/test/linear_cli/linear/paginate_test.exs | 17 ++++++++ documents/ash-domain-erd.adoc | 17 ++++---- 10 files changed, 131 insertions(+), 47 deletions(-) diff --git a/Readme.adoc b/Readme.adoc index df3719d..206f226 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -314,11 +314,12 @@ project and assignee matches are used without a prompt. Partial matches prompt you to choose a project or assignee. Before any filtered mutation, the command asks for confirmation; `--yes` skips that prompt and `--dry-run` prints the matching issues without changing them. -The command resolves all matching issues before it starts updates, including -matches beyond the normal 100-record issue-list page limit. Updates remain +The command processes at most 100 matches at a time. If more than 100 issues +match, it prints a warning and processes only the first 100. Updates remain independent API calls, so a failed request can leave earlier matches already -unassigned. The existing `--unassigned` issue-list filter is not a selector for -this command because unassigning already-unassigned issues has no effect. +unassigned. In JSON mode, the warning goes to stderr so stdout stays valid +JSON. The existing `--unassigned` issue-list filter is not a selector for this +command because unassigning already-unassigned issues has no effect. ==== Create an issue diff --git a/app/lib/linear_cli/cli/commands/issues/mutations.ex b/app/lib/linear_cli/cli/commands/issues/mutations.ex index c218e9b..38c15ff 100644 --- a/app/lib/linear_cli/cli/commands/issues/mutations.ex +++ b/app/lib/linear_cli/cli/commands/issues/mutations.ex @@ -188,14 +188,18 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do Filter.build_input(flags, options, [], project_resolution: :strict, include_labels: false, - fetch_all_pages: true, resolve_assignee: true ), - {:ok, issues} <- Linear.issues(input) do - unassign_filtered_issues(issues, flags, options) + {:ok, issues, has_next_page} <- Linear.issues_first_page(input) do + unassign_filtered_issues(issues, has_next_page, flags, options) end end + defp unassign_filtered_issues(issues, has_next_page, flags, options) do + warn_if_truncated(has_next_page, options) + unassign_filtered_issues(issues, flags, options) + end + defp unassign_filtered_issues([], _flags, options), do: show_unassign_results([], options) defp unassign_filtered_issues(issues, %{dry_run: true}, options) do @@ -241,6 +245,18 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do :ok end + defp warn_if_truncated(false, _options), do: :ok + + defp warn_if_truncated(true, options) do + message = "More than 100 issues match this filter. Only the first 100 will be processed." + + if Map.get(options, :output, "text") == "json" do + Prompt.warn(message, :stderr) + else + Prompt.warn(message) + end + end + defp show_unassign_results([], options) do if Map.get(options, :output) == "json" do Display.show([], %{output: "json"}) diff --git a/app/lib/linear_cli/cli/prompt.ex b/app/lib/linear_cli/cli/prompt.ex index 83e93d0..ffa3ec8 100644 --- a/app/lib/linear_cli/cli/prompt.ex +++ b/app/lib/linear_cli/cli/prompt.ex @@ -21,7 +21,7 @@ defmodule LinearCli.CLI.Prompt do ## Testing without a real terminal - `ok/1`, `warn/1`, and `say/1` only ever write to stdout, so, like every + `ok/1`, `warn/1`, and `say/1` write to stdout by default, so, like every other command output in this codebase, they're already covered by `ExUnit.CaptureIO.capture_io/1` (see `LinearCli.CLITest`). @@ -52,13 +52,13 @@ defmodule LinearCli.CLI.Prompt do assert Prompt.ask("Title", default: "untitled") == "untitled" end) - There is deliberately no injectable io-device parameter anywhere on this - module's own API - the injection point is the group leader that - `ExUnit.CaptureIO` already owns, one level below this module (inside Owl). - Any other module that calls into `LinearCli.CLI.Prompt` is testable the - same way, with no plumbing of its own required - it's the same pattern - `LinearCli.CLI.main/2` uses an injectable `halt` function for (different - mechanism, same goal: keep side effects swappable in tests). + There is deliberately no injectable input-device parameter on this module's + API. The input injection point is the group leader that `ExUnit.CaptureIO` + already owns, one level below this module (inside Owl). Output warnings can + name a device when a command must keep standard output machine-readable. + Any other module that calls into `LinearCli.CLI.Prompt` is testable the same + way, with no plumbing of its own required - it uses the same pattern as + `LinearCli.CLI.main/2`'s injectable `halt` function. `edit/2` is the one function here that doesn't touch stdin/stdout at all - it shells out to an external editor via `Owl.IO.open_in_editor/2`, so it @@ -85,8 +85,8 @@ defmodule LinearCli.CLI.Prompt do Ported from `TTY::Prompt#warn`. """ - @spec warn(Owl.Data.t()) :: :ok - def warn(message), do: Owl.IO.puts(Owl.Data.tag(message, :yellow)) + @spec warn(Owl.Data.t(), IO.device()) :: :ok + def warn(message, device \\ :stdio), do: Owl.IO.puts(Owl.Data.tag(message, :yellow), device) @doc """ Prints `message` plainly, with no color. diff --git a/app/lib/linear_cli/linear.ex b/app/lib/linear_cli/linear.ex index 97d37ec..ee994b5 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -65,4 +65,9 @@ defmodule LinearCli.Linear do define :delete_issue_relation, action: :destroy end end + + @doc "Fetches one issue page and returns whether more matches exist." + def issues_first_page(input) do + LinearCli.Linear.Issue.Read.List.first_page(input) + end end diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index 85d1fa3..fdc8450 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -191,6 +191,19 @@ defmodule LinearCli.Linear.Issue.Read.List do end end + @doc """ + Fetches one issue page for filter-only batch commands. + + Returns `{:ok, issues, has_next_page}` and never follows the continuation + cursor. The caller can warn before it processes the first 100 matches. + """ + def first_page(args) do + list_first_page( + build_filter(args), + Map.get(args, :include_labels, false) || Map.get(args, :labels, []) != [] + ) + end + # Ruby: BaseModel::ClassMethods#find - singular `issue(id:)` lookup, full_fragment. # A function, not a module attribute: its body reaches into User/Team/Comment # (other files), so it must be evaluated at call time, not at compile time of @@ -262,6 +275,17 @@ defmodule LinearCli.Linear.Issue.Read.List do ) end + defp list_first_page(filter, include_labels) do + document = if include_labels, do: list_document_with_labels(), else: list_document() + + Paginate.first_page( + document, + "issues", + fn _after_cursor -> %{"filter" => filter, "first" => 100, "after" => nil} end, + &Issue.from_map/1 + ) + end + # 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 diff --git a/app/lib/linear_cli/linear/paginate.ex b/app/lib/linear_cli/linear/paginate.ex index bdfb84c..732e3e5 100644 --- a/app/lib/linear_cli/linear/paginate.ex +++ b/app/lib/linear_cli/linear/paginate.ex @@ -22,6 +22,25 @@ defmodule LinearCli.Linear.Paginate do do_all(document, field_name, variables_fun, decode_fun, nil, max, []) end + @doc """ + Fetches one GraphQL connection page and returns its decoded records and + `hasNextPage` value. + + The caller controls the page size in `variables_fun`. This function never + follows the continuation cursor. + """ + def first_page(document, field_name, variables_fun, decode_fun) do + with {:ok, data} <- Api.call(document, variables_fun.(nil)), + {:ok, %{"edges" => edges, "pageInfo" => page_info}} <- + fetch_connection(data, field_name) do + values = Enum.map(edges, &decode_fun.(&1["node"])) + {:ok, values, page_info["hasNextPage"] == true} + else + {:error, {:http_error, status, _body}} -> {:error, {:http_error, status}} + {:error, reason} -> {:error, reason} + end + end + defp do_all(document, field_name, variables_fun, decode_fun, after_cursor, max, acc) do with {:ok, data} <- Api.call(document, variables_fun.(after_cursor)), {:ok, %{"edges" => edges, "pageInfo" => page_info}} <- diff --git a/app/test/linear_cli/cli/commands/issues/mutations_test.exs b/app/test/linear_cli/cli/commands/issues/mutations_test.exs index 84fd5ec..bd2187f 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -197,7 +197,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do ]) end) - assert_received {:issue_filter, %{"filter" => filter, "first" => 50, "after" => nil}} + assert_received {:issue_filter, %{"filter" => filter, "first" => 100, "after" => nil}} assert filter["assignee"] == %{"id" => %{"eq" => "u1"}} refute get_in(filter, ["assignee", "isMe"]) assert_received {:unassign_input, %{"assigneeId" => nil}} @@ -205,7 +205,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert output =~ "CRY-1 unassigned" end - test "fetches all filtered pages before unassigning more than 100 matches" do + test "limits a filtered batch to 100 matches and warns when more exist" do test_pid = self() page_issues = fn first, last -> @@ -226,9 +226,12 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do page = case cursor do - nil -> issues_response_page(page_issues.(1, 50), true, "c1") - "c1" -> issues_response_page(page_issues.(51, 100), true, "c2") - "c2" -> issues_response_page(page_issues.(101, 120), false, "c3") + nil -> + assert decoded["variables"]["first"] == 100 + issues_response_page(page_issues.(1, 100), true, "c100") + + _ -> + issues_response_page([], false, cursor) end Req.Test.json(conn, page) @@ -249,8 +252,6 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do LinearCli.CLI.main([ "issue", "unassign", - "--output", - "json", "--no-profile", "--team", "ENG", @@ -259,24 +260,20 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end) assert_received {:page_requested, nil} - assert_received {:page_requested, "c1"} - assert_received {:page_requested, "c2"} - - assert {:ok, updated} = Jason.decode(output) - assert length(updated) == 120 - assert List.first(updated)["identifier"] == "CRY-1" - assert List.last(updated)["identifier"] == "CRY-120" + refute_received {:page_requested, _} + assert output =~ "More than 100 issues match this filter" + assert output =~ "Only the first 100 will be processed" updated_ids = - Enum.reduce(1..120, [], fn _number, acc -> + Enum.reduce(1..100, [], fn _number, acc -> receive do {:updated, identifier} -> [identifier | acc] after - 1_000 -> flunk("expected all 120 issue updates") + 1_000 -> flunk("expected all 100 issue updates") end end) - assert length(updated_ids) == 120 + assert length(updated_ids) == 100 end test "confirms the filtered batch and cancels without mutating" do @@ -643,7 +640,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert filter["assignee"] == %{"id" => %{"eq" => "u1"}} end - test "fetches every page before starting updates" do + test "reports a first-page read error without mutating" do test_pid = self() halt = fn code -> send(test_pid, {:halted, code}) end @@ -654,13 +651,10 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do cond do String.contains?(query, "issues(filter:") -> - case decoded["variables"]["after"] do - nil -> Req.Test.json(conn, issues_response_page([issue_map()], true, "c1")) - "c1" -> Plug.Conn.resp(conn, 502, "upstream unavailable") - end + Plug.Conn.resp(conn, 502, "upstream unavailable") String.contains?(query, "issueUpdate") -> - raise "no update is allowed after a later-page read error" + raise "no update is allowed after a first-page read error" true -> raise "no stub matched query: #{query}" diff --git a/app/test/linear_cli/cli/prompt_test.exs b/app/test/linear_cli/cli/prompt_test.exs index f41201c..0bbff21 100644 --- a/app/test/linear_cli/cli/prompt_test.exs +++ b/app/test/linear_cli/cli/prompt_test.exs @@ -15,6 +15,11 @@ defmodule LinearCli.CLI.PromptTest do assert capture_io(fn -> Prompt.warn("Careful now") end) == "\e[33mCareful now\e[39m\e[0m\n" end + + test "can print the message to stderr" do + assert capture_io(:stderr, fn -> Prompt.warn("Careful now", :stderr) end) == + "\e[33mCareful now\e[39m\e[0m\n" + end end describe "say/1" do diff --git a/app/test/linear_cli/linear/paginate_test.exs b/app/test/linear_cli/linear/paginate_test.exs index b5dcb5d..36deef2 100644 --- a/app/test/linear_cli/linear/paginate_test.exs +++ b/app/test/linear_cli/linear/paginate_test.exs @@ -42,6 +42,23 @@ defmodule LinearCli.Linear.PaginateTest do refute_receive {:cursor, "c2"} end + test "returns the first page and reports more records without following the cursor" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + cursor = Jason.decode!(body)["variables"]["after"] + send(test_pid, {:cursor, cursor}) + Req.Test.json(conn, response([1, 2], true, "c1")) + end) + + assert {:ok, [1, 2], true} = + Paginate.first_page("query", "issues", &variables_fun/1, & &1["id"]) + + assert_receive {:cursor, nil} + refute_receive {:cursor, "c1"} + end + test "fetches every page when max is infinity" do test_pid = self() diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 610ce94..c28a57d 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -368,8 +368,10 @@ manual-implementation module, and the Linear GraphQL operation it calls. label filter — `labels` non-empty also forces label-field selection as a defensive invariant; `assignee` filters by exact case-insensitive assignee name when no resolved ID is supplied, while `assignee_id` filters by exact user ID; and - `fetch_all_pages` opts into an unbounded cursor walk for filter-only batch - operations; or `issue(id:)` per id (full, fanned concurrently) + `fetch_all_pages` opts into an unbounded cursor walk for callers that need + every page; or `issue(id:)` per id (full, fanned concurrently). The + `Linear.issues_first_page/1` helper fetches one 100-record page and returns + `hasNextPage` for capped filter-only batch operations. | `Issue` | `create_issue` @@ -544,9 +546,9 @@ construction for `issue list` and filter-only `issue unassign`. `build_input/4` applies the active profile's team and project defaults unless `--no-profile` is set, resolves a project in the selected team (or workspace), and returns the common issue-list arguments for the domain. Filter-only -unassignment requests strict project matching and enables `fetch_all_pages`; -ordinary issue listing keeps the existing permissive project resolution and -100-record cap. +unassignment requests strict project matching and uses the one-page issue +helper, which caps the batch at 100 and reports more matches. Ordinary issue +listing keeps the existing permissive project resolution and 100-record cap. === `LinearCli.Linear.Paginate` @@ -556,14 +558,15 @@ Provides `all/5`: fetches cursor-paginated GraphQL connections (`edges { node { ... } cursor } pageInfo { hasNextPage endCursor }`) until `max` records are collected or the API signals no more pages. Passing `:infinity` fetches every advertised page, and a repeated continuation cursor -returns an error instead of looping forever. +returns an error instead of looping forever. `first_page/4` fetches one page, +returns its `hasNextPage` value, and never follows a continuation cursor. Used by: * `Team.Read.All` — `teams(first:, after:)` paginated query * `Project.Read.All` — `projects(first:, after:)` paginated query * `Issue.Read.List` — `issues(filter:, first:, after:)` paginated list (the find-by-ids path fans out individual `issue(id:)` calls instead; the - filter-only unassign path opts into `:infinity`) + filter-only unassign path uses `first_page/4`) == Maintenance contract From 981cf860b75691dae97615d7375f3bc8fc76ba0a Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 26 Sep 2026 13:29:17 -0400 Subject: [PATCH 4/5] fix(issue): skip unassigned filter matches --- Readme.adoc | 3 +- .../linear_cli/cli/commands/issues/filter.ex | 8 +--- .../cli/commands/issues/mutations.ex | 5 ++- app/lib/linear_cli/linear.ex | 6 +-- app/lib/linear_cli/linear/issue.ex | 42 ++++++++++++------ app/lib/linear_cli/linear/paginate.ex | 4 +- .../cli/commands/issues/mutations_test.exs | 1 + app/test/linear_cli/linear/issue_test.exs | 14 ++++++ app/test/linear_cli/linear/paginate_test.exs | 43 +------------------ documents/ash-domain-erd.adoc | 30 +++++++------ 10 files changed, 71 insertions(+), 85 deletions(-) diff --git a/Readme.adoc b/Readme.adoc index 206f226..47178c0 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -309,7 +309,8 @@ Text output shows each updated issue and a confirmation. JSON output returns one object for one issue and an array for multiple issues. A filter with no matches prints `No issues matched.` in text mode and returns `[]` in JSON mode. Filter mode selects only your own issues by default; use `--no-mine` to include -issues assigned to other users, or `--assignee` to select a named user. Exact +issues assigned to other users, or `--assignee` to select a named user. Already +unassigned issues are skipped. Exact project and assignee matches are used without a prompt. Partial matches prompt you to choose a project or assignee. Before any filtered mutation, the command asks for confirmation; `--yes` skips that prompt and `--dry-run` prints the diff --git a/app/lib/linear_cli/cli/commands/issues/filter.ex b/app/lib/linear_cli/cli/commands/issues/filter.ex index 40d6e2a..e23d64d 100644 --- a/app/lib/linear_cli/cli/commands/issues/filter.ex +++ b/app/lib/linear_cli/cli/commands/issues/filter.ex @@ -35,8 +35,8 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do ids: ids, mine: not Map.get(flags, :no_mine, false), unassigned: Keyword.get(opts, :unassigned, Map.get(flags, :unassigned, false)), - assignee: assignee_for_input(options, assignee_id), assignee_id: assignee_id, + assigned_only: Keyword.get(opts, :assigned_only, false), team_key: team_key, project_id: project_id, all: Map.get(flags, :all, false), @@ -44,14 +44,10 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do status: Map.get(options, :status) || [], labels: labels, include_labels: - Keyword.get(opts, :include_labels, Map.get(flags, :include_labels, false) || labels != []), - fetch_all_pages: Keyword.get(opts, :fetch_all_pages, false) + Keyword.get(opts, :include_labels, Map.get(flags, :include_labels, false) || labels != []) } end - defp assignee_for_input(options, nil), do: Map.get(options, :assignee) - defp assignee_for_input(_options, _assignee_id), do: nil - defp resolve_project_id(nil, _team_key, _resolution), do: {:ok, nil} defp resolve_project_id(search, team_key, resolution) when is_binary(team_key) do diff --git a/app/lib/linear_cli/cli/commands/issues/mutations.ex b/app/lib/linear_cli/cli/commands/issues/mutations.ex index 38c15ff..86913ca 100644 --- a/app/lib/linear_cli/cli/commands/issues/mutations.ex +++ b/app/lib/linear_cli/cli/commands/issues/mutations.ex @@ -188,9 +188,10 @@ defmodule LinearCli.CLI.Commands.Issues.Mutations do Filter.build_input(flags, options, [], project_resolution: :strict, include_labels: false, - resolve_assignee: true + resolve_assignee: true, + assigned_only: true ), - {:ok, issues, has_next_page} <- Linear.issues_first_page(input) do + {:ok, %{issues: issues, has_next_page: has_next_page}} <- Linear.issues_first_page(input) do unassign_filtered_issues(issues, has_next_page, flags, options) end end diff --git a/app/lib/linear_cli/linear.ex b/app/lib/linear_cli/linear.ex index ee994b5..082b2a8 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -32,6 +32,7 @@ defmodule LinearCli.Linear do resource LinearCli.Linear.Issue do define :issues, action: :list + define :issues_first_page, action: :list_first_page, args: [:input] define :create_issue, action: :create, args: [:title, :description, :team_id] define :assign_issue, action: :assign, args: [:assignee_id] define :unassign_issue, action: :unassign @@ -65,9 +66,4 @@ defmodule LinearCli.Linear do define :delete_issue_relation, action: :destroy end end - - @doc "Fetches one issue page and returns whether more matches exist." - def issues_first_page(input) do - LinearCli.Linear.Issue.Read.List.first_page(input) - end end diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index fdc8450..9811074 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -11,8 +11,8 @@ defmodule LinearCli.Linear.Issue do argument :ids, {:array, :string}, default: [] argument :mine, :boolean, default: true argument :unassigned, :boolean, default: false - argument :assignee, :string, allow_nil?: true argument :assignee_id, :string, allow_nil?: true + argument :assigned_only, :boolean, default: false argument :team_key, :string, allow_nil?: true argument :project_id, :string, allow_nil?: true argument :all, :boolean, default: false @@ -20,10 +20,14 @@ defmodule LinearCli.Linear.Issue do argument :status, {:array, :string}, default: [] argument :labels, {:array, :string}, default: [] argument :include_labels, :boolean, default: false - argument :fetch_all_pages, :boolean, default: false manual LinearCli.Linear.Issue.Read.List end + action :list_first_page, :map do + argument :input, :map, allow_nil?: false + run LinearCli.Linear.Issue.Actions.ListFirstPage + end + # Ruby: Issue::ClassMethods#create(title:, description:, team:, project:, labels: []) create :create do argument :title, :string, allow_nil?: false @@ -183,11 +187,7 @@ defmodule LinearCli.Linear.Issue.Read.List do if args.ids != [] do find_by_ids(args.ids) else - list_all( - build_filter(args), - args.include_labels || args.labels != [], - args.fetch_all_pages - ) + list_all(build_filter(args), args.include_labels || args.labels != []) end end @@ -263,7 +263,7 @@ defmodule LinearCli.Linear.Issue.Read.List do end end - defp list_all(filter, include_labels, fetch_all_pages) do + defp list_all(filter, include_labels) do document = if include_labels, do: list_document_with_labels(), else: list_document() Paginate.all( @@ -271,7 +271,7 @@ defmodule LinearCli.Linear.Issue.Read.List do "issues", fn after_cursor -> %{"filter" => filter, "first" => 50, "after" => after_cursor} end, &Issue.from_map/1, - if(fetch_all_pages, do: :infinity, else: 100) + 100 ) end @@ -336,11 +336,6 @@ defmodule LinearCli.Linear.Issue.Read.List do Map.put(filter, "assignee", %{"id" => %{"eq" => assignee_id}}) end - defp maybe_put_assignee_filter(filter, %{assignee: assignee}) - when is_binary(assignee) and assignee != "" do - Map.put(filter, "assignee", %{"name" => %{"eqIgnoreCase" => assignee}}) - end - defp maybe_put_assignee_filter(filter, %{unassigned: true}) do Map.put(filter, "assignee", %{"null" => true}) end @@ -349,6 +344,10 @@ defmodule LinearCli.Linear.Issue.Read.List do Map.put(filter, "assignee", %{"isMe" => %{"eq" => true}}) end + defp maybe_put_assignee_filter(filter, %{assigned_only: true}) do + Map.put(filter, "assignee", %{"null" => false}) + end + defp maybe_put_assignee_filter(filter, _args), do: filter defp maybe_put_team_filter(filter, %{team_key: key}) when is_binary(key) do @@ -403,6 +402,21 @@ defmodule LinearCli.Linear.Issue.Read.List do end end +defmodule LinearCli.Linear.Issue.Actions.ListFirstPage do + @moduledoc false + use Ash.Resource.Actions.Implementation + + alias LinearCli.Linear.Issue.Read.List + + @impl true + def run(input, _opts, _context) do + case List.first_page(input.arguments.input) do + {:ok, issues, has_next_page} -> {:ok, %{issues: issues, has_next_page: has_next_page}} + error -> error + end + end +end + defmodule LinearCli.Linear.Issue.Create do @moduledoc false use Ash.Resource.ManualCreate diff --git a/app/lib/linear_cli/linear/paginate.ex b/app/lib/linear_cli/linear/paginate.ex index 732e3e5..f7e3849 100644 --- a/app/lib/linear_cli/linear/paginate.ex +++ b/app/lib/linear_cli/linear/paginate.ex @@ -12,7 +12,7 @@ defmodule LinearCli.Linear.Paginate do @doc """ Fetches pages via `LinearCli.Api.call(document, variables(after_cursor))` until `max` records are collected or the API reports no more pages, decoding each raw - node through `decode_fun`. Pass `:infinity` to fetch every page. + node through `decode_fun`. `field_name` is the top-level response key (e.g. `"teams"`) holding `edges`/`pageInfo`. `variables_fun` receives the current `after` cursor @@ -64,10 +64,8 @@ defmodule LinearCli.Linear.Paginate do end end - defp reached_limit?(_acc, :infinity), do: false defp reached_limit?(acc, max), do: length(acc) >= max - defp take_max(acc, :infinity), do: acc defp take_max(acc, max), do: Enum.take(acc, max) # Safely extracts the named connection from the response data. Returns diff --git a/app/test/linear_cli/cli/commands/issues/mutations_test.exs b/app/test/linear_cli/cli/commands/issues/mutations_test.exs index bd2187f..3a0fc1d 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -433,6 +433,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert_received {:issue_filter, filter} assert filter["team"] == %{"key" => %{"eq" => "ENG"}} + assert filter["assignee"] == %{"null" => false} assert filter["state"]["type"] == %{"in" => ["started"]} assert filter["state"]["name"] == %{"eqIgnoreCase" => "Human Review"} diff --git a/app/test/linear_cli/linear/issue_test.exs b/app/test/linear_cli/linear/issue_test.exs index 05e2803..1faf1e2 100644 --- a/app/test/linear_cli/linear/issue_test.exs +++ b/app/test/linear_cli/linear/issue_test.exs @@ -63,6 +63,20 @@ defmodule LinearCli.Linear.IssueTest do assert {:ok, []} = Linear.issues(%{unassigned: true}) end + test "issues/1 with mine: false does not add an assignee filter" 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, "assignee") + + Req.Test.json(conn, %{ + "data" => %{"issues" => %{"edges" => [], "pageInfo" => %{"hasNextPage" => false}}} + }) + end) + + assert {:ok, []} = Linear.issues(%{mine: false}) + 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) diff --git a/app/test/linear_cli/linear/paginate_test.exs b/app/test/linear_cli/linear/paginate_test.exs index 36deef2..fc764ab 100644 --- a/app/test/linear_cli/linear/paginate_test.exs +++ b/app/test/linear_cli/linear/paginate_test.exs @@ -59,47 +59,6 @@ defmodule LinearCli.Linear.PaginateTest do refute_receive {:cursor, "c1"} end - test "fetches every page when max is infinity" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - cursor = Jason.decode!(body)["variables"]["after"] - send(test_pid, {:cursor, cursor}) - - response = - case cursor do - nil -> response([1], true, "c1") - "c1" -> response([2], true, "c2") - "c2" -> response([3], false, "c3") - end - - Req.Test.json(conn, response) - end) - - assert {:ok, [1, 2, 3]} = - Paginate.all("query", "issues", &variables_fun/1, & &1["id"], :infinity) - - assert_receive {:cursor, nil} - assert_receive {:cursor, "c1"} - assert_receive {:cursor, "c2"} - end - - test "returns a later-page error before completing an unbounded read" do - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - cursor = Jason.decode!(body)["variables"]["after"] - - case cursor do - nil -> Req.Test.json(conn, response([1], true, "c1")) - "c1" -> Plug.Conn.resp(conn, 502, "upstream unavailable") - end - end) - - assert {:error, {:http_error, 502}} = - Paginate.all("query", "issues", &variables_fun/1, & &1["id"], :infinity) - end - test "returns an error when the API repeats a continuation cursor" do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) @@ -115,6 +74,6 @@ defmodule LinearCli.Linear.PaginateTest do end) assert {:error, {:non_advancing_cursor, "c1"}} = - Paginate.all("query", "issues", &variables_fun/1, & &1["id"], :infinity) + Paginate.all("query", "issues", &variables_fun/1, & &1["id"]) end end diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index c28a57d..6798e57 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -366,12 +366,17 @@ manual-implementation module, and the Linear GraphQL operation it calls. (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; `assignee` filters by exact case-insensitive assignee name when no - resolved ID is supplied, while `assignee_id` filters by exact user ID; and - `fetch_all_pages` opts into an unbounded cursor walk for callers that need - every page; or `issue(id:)` per id (full, fanned concurrently). The - `Linear.issues_first_page/1` helper fetches one 100-record page and returns - `hasNextPage` for capped filter-only batch operations. + invariant; `assignee_id` filters by exact user ID; `assigned_only` excludes + unassigned issues for filter-only batch operations; or `issue(id:)` per id + (full, fanned concurrently). + +| `Issue` +| `issues_first_page` +| `:list_first_page` +| generic action (`:map`) +| `Linear.Issue.Actions.ListFirstPage` +| `input` contains the `Issue.list` arguments; returns `%{issues:, has_next_page:}` + after fetching one 100-record page. The action never follows the cursor. | `Issue` | `create_issue` @@ -556,17 +561,18 @@ listing keeps the existing permissive project resolution and 100-record cap. Provides `all/5`: fetches cursor-paginated GraphQL connections (`edges { node { ... } cursor } pageInfo { hasNextPage endCursor }`) -until `max` records are collected or the API signals no more pages. Passing -`:infinity` fetches every advertised page, and a repeated continuation cursor -returns an error instead of looping forever. `first_page/4` fetches one page, -returns its `hasNextPage` value, and never follows a continuation cursor. +until `max` records are collected or the API signals no more pages. It returns +an error for a repeated continuation cursor instead of looping forever. +`first_page/4` fetches one page, returns its `hasNextPage` value, and never +follows a continuation cursor. Used by: * `Team.Read.All` — `teams(first:, after:)` paginated query * `Project.Read.All` — `projects(first:, after:)` paginated query * `Issue.Read.List` — `issues(filter:, first:, after:)` paginated list - (the find-by-ids path fans out individual `issue(id:)` calls instead; the - filter-only unassign path uses `first_page/4`) + (the find-by-ids path fans out individual `issue(id:)` calls instead) +* `Issue.Actions.ListFirstPage` — wraps `Issue.Read.List.first_page/1` in an + Ash generic action for the filter-only unassign path == Maintenance contract From 35ebee80dff185a8603f95092b13168e5ea7f31b Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 26 Sep 2026 13:34:25 -0400 Subject: [PATCH 5/5] test(issue): cover assigned-only filter invocation --- app/test/linear_cli/cli/commands/issues/mutations_test.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/app/test/linear_cli/cli/commands/issues/mutations_test.exs b/app/test/linear_cli/cli/commands/issues/mutations_test.exs index 3a0fc1d..53e717c 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -420,6 +420,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do "unassign", "--no-profile", "--no-mine", + "--yes", "--team", "ENG", "--state",