From 2a7b4575666a2efbe0a1289198e807a2bae7d98d Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Mon, 28 Sep 2026 09:27:44 -0400 Subject: [PATCH 1/4] chore(deps): update Mint security fixes --- app/mix.lock | 2 +- mix.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/mix.lock b/app/mix.lock index 6e11425..9ac94f5 100644 --- a/app/mix.lock +++ b/app/mix.lock @@ -31,7 +31,7 @@ "mdex_multiline_cells": {:hex, :mdex_multiline_cells, "0.1.0", "b6641ab15bd0d3b68cdc41e05f45467da7572d74e089ad320fc657458922ba96", [:mix], [{:mdex, "~> 0.13", [hex: :mdex, repo: "hexpm", optional: false]}], "hexpm", "9b0665d1342c3ae74d48508adee669459e0b3dafc993eb1c7ea15ae73525647d"}, "mdex_native": {:hex, :mdex_native, "0.2.8", "20b7cbf330c1ca81b8da4132b8d01952cded11f6dfc2abe8fef25c13681b15e4", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "004a5565b6c96a06400901eb1e4e603585e00b23262d3f595c3f4aa38b83ef66"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"}, + "mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "multigraph": {:hex, :multigraph, "0.16.1-mg.5", "5118665ea3e2eede7244104ccc716b7c517bba44e33c26fb84d7297d77872646", [:mix], [], "hexpm", "d7e3511f1d29e96ed7e7cd01b92bb52eea487b6c61da838f1e61a2ec6838799d"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, diff --git a/mix.lock b/mix.lock index f1cc286..8067448 100644 --- a/mix.lock +++ b/mix.lock @@ -3,7 +3,7 @@ "hpax": {:hex, :hpax, "1.1.0", "782931867cc23217c68fb5f68fe1a11f5e7544c7fda82c8a7019a5df5a4a1cdf", [:mix], [], "hexpm", "0b8d0f05832f55571d65ac720f79bf8994138ffbb133209dc4685eae0ad456a8"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.10.1", "c53e70867cf74017716884d8d33e0742b08b32e9cdb0031cbc69a429dc5555e3", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "0ba2a904605ed8406393444fb8b3356dc58eb59ee6c7fb94ac3f015e1be129e8"}, + "mint": {:hex, :mint, "1.11.0", "a713551624815c0435237b93d90ea8b9b14254690c66f732d0ef8930f76ff1d9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "c6279ba2d6aa3a383a1d4cfbe7b59f42e6efd400f58d8e2acfeac48a438693ab"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, From d2fd6f70d41736f498ed605b8b50a2fe71ba64a9 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Mon, 28 Sep 2026 09:28:00 -0400 Subject: [PATCH 2/4] fix(issues): paginate workspace lookup candidates Resolve workspace-wide project and assignee candidates across every page while preserving bounded issue operations and team-scoped lookups.\n\nRefs EXT-71. --- .../linear_cli/cli/commands/issues/filter.ex | 6 +- .../linear_cli/cli/commands/issues/read.ex | 2 +- app/lib/linear_cli/linear.ex | 3 + app/lib/linear_cli/linear/paginate.ex | 12 + app/lib/linear_cli/linear/project.ex | 29 +++ app/lib/linear_cli/linear/team.ex | 29 +++ app/lib/linear_cli/linear/user.ex | 55 +++++ .../cli/commands/issues/mutations_test.exs | 231 ++++++++++++++++++ .../cli/commands/issues/read_test.exs | 91 +++++++ app/test/linear_cli/linear/paginate_test.exs | 40 +++ app/test/linear_cli/linear/project_test.exs | 30 +++ app/test/linear_cli/linear/team_test.exs | 30 +++ app/test/linear_cli/linear/user_test.exs | 35 +++ app/test/support/issue_commands_helpers.ex | 11 + documents/ash-domain-erd.adoc | 39 +++ 15 files changed, 639 insertions(+), 4 deletions(-) diff --git a/app/lib/linear_cli/cli/commands/issues/filter.ex b/app/lib/linear_cli/cli/commands/issues/filter.ex index 2732c17..1c8c1fb 100644 --- a/app/lib/linear_cli/cli/commands/issues/filter.ex +++ b/app/lib/linear_cli/cli/commands/issues/filter.ex @@ -58,7 +58,7 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do end defp resolve_project_id(search, _team_key, resolution) do - with {:ok, projects} <- Linear.projects() do + with {:ok, projects} <- Linear.workspace_projects() do resolve_project_match(projects, search, resolution) end end @@ -127,7 +127,7 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do end defp assignee_members(nil) do - with {:ok, teams} <- Linear.teams() do + with {:ok, teams} <- Linear.workspace_teams() do teams |> Enum.reduce_while({:ok, %{}}, &collect_team_members/2) |> members_from_result() @@ -135,7 +135,7 @@ defmodule LinearCli.CLI.Commands.Issues.Filter do end defp collect_team_members(team, {:ok, members_by_id}) do - case Linear.team_members(team.id) do + case Linear.workspace_team_members(team.id) do {:ok, members} -> members_by_id = Enum.reduce(members, members_by_id, fn member, acc -> diff --git a/app/lib/linear_cli/cli/commands/issues/read.ex b/app/lib/linear_cli/cli/commands/issues/read.ex index d63d132..e0f91e6 100644 --- a/app/lib/linear_cli/cli/commands/issues/read.ex +++ b/app/lib/linear_cli/cli/commands/issues/read.ex @@ -18,7 +18,7 @@ defmodule LinearCli.CLI.Commands.Issues.Read do `--project`/`-p` resolution is team-scoped when `--team` is given (or the active profile supplies a team) - it searches that team's projects via `projects_by_team`. Without a team context it falls back to all workspace - projects (`Project.all`). Prompts interactively when the search is + projects (`Project.workspace_projects`). Prompts interactively when the search is ambiguous or omitted-but-requested (`-p -`). Only resolved at all when `--project` was actually given (or `LinearCli.Profiles.default_project/0` supplies one) - unlike `issue create`/`issue update`, a bare `issue list` diff --git a/app/lib/linear_cli/linear.ex b/app/lib/linear_cli/linear.ex index 082b2a8..66cee67 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -14,16 +14,19 @@ defmodule LinearCli.Linear do resource LinearCli.Linear.User do define :me, action: :me, get?: true define :team_members, action: :by_team, args: [:team_id] + define :workspace_team_members, action: :by_team_for_lookup, args: [:team_id] end resource LinearCli.Linear.Team do define :teams, action: :all + define :workspace_teams, action: :all_for_lookup define :my_teams, action: :mine define :find_team, action: :find, args: [:id], get?: true end resource LinearCli.Linear.Project do define :projects, action: :all + define :workspace_projects, action: :all_for_lookup define :my_projects, action: :mine define :projects_by_team, action: :by_team, args: [:team_id] define :create_project, action: :create, args: [:name, :team_id] diff --git a/app/lib/linear_cli/linear/paginate.ex b/app/lib/linear_cli/linear/paginate.ex index f7e3849..de0c647 100644 --- a/app/lib/linear_cli/linear/paginate.ex +++ b/app/lib/linear_cli/linear/paginate.ex @@ -22,6 +22,16 @@ defmodule LinearCli.Linear.Paginate do do_all(document, field_name, variables_fun, decode_fun, nil, max, []) end + @doc """ + Fetches every page of a GraphQL connection and decodes each record. + + This variant has no record limit. Use it only for lookup candidate sets that + must be complete before matching, rather than for bounded issue operations. + """ + def all_pages(document, field_name, variables_fun, decode_fun) do + do_all(document, field_name, variables_fun, decode_fun, nil, :unbounded, []) + end + @doc """ Fetches one GraphQL connection page and returns its decoded records and `hasNextPage` value. @@ -64,8 +74,10 @@ defmodule LinearCli.Linear.Paginate do end end + defp reached_limit?(_acc, :unbounded), do: false defp reached_limit?(acc, max), do: length(acc) >= max + defp take_max(acc, :unbounded), 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/lib/linear_cli/linear/project.ex b/app/lib/linear_cli/linear/project.ex index 9e29393..597476c 100644 --- a/app/lib/linear_cli/linear/project.ex +++ b/app/lib/linear_cli/linear/project.ex @@ -10,6 +10,10 @@ defmodule LinearCli.Linear.Project do manual LinearCli.Linear.Project.Read.All end + read :all_for_lookup do + manual LinearCli.Linear.Project.Read.AllForLookup + end + read :mine do manual LinearCli.Linear.Project.Read.Mine end @@ -172,6 +176,31 @@ defmodule LinearCli.Linear.Project.Read.All do end end +defmodule LinearCli.Linear.Project.Read.AllForLookup do + @moduledoc false + use Ash.Resource.ManualRead + + alias LinearCli.Linear.{Paginate, Project} + + @document """ + query($first: Int!, $after: String) { + projects(first: $first, after: $after) { + edges { node { #{Project.base_fields()} } cursor } + pageInfo { hasNextPage endCursor } + } + } + """ + + def read(_query, _ecto_query, _opts, _context) do + Paginate.all_pages( + @document, + "projects", + fn after_cursor -> %{"first" => 50, "after" => after_cursor} end, + &Project.from_map/1 + ) + end +end + defmodule LinearCli.Linear.Project.Read.ByTeam do @moduledoc false use Ash.Resource.ManualRead diff --git a/app/lib/linear_cli/linear/team.ex b/app/lib/linear_cli/linear/team.ex index ec39049..d0f72d6 100644 --- a/app/lib/linear_cli/linear/team.ex +++ b/app/lib/linear_cli/linear/team.ex @@ -10,6 +10,10 @@ defmodule LinearCli.Linear.Team do manual LinearCli.Linear.Team.Read.All end + read :all_for_lookup do + manual LinearCli.Linear.Team.Read.AllForLookup + end + read :mine do manual LinearCli.Linear.Team.Read.Mine end @@ -82,6 +86,31 @@ defmodule LinearCli.Linear.Team.Read.All do end end +defmodule LinearCli.Linear.Team.Read.AllForLookup do + @moduledoc false + use Ash.Resource.ManualRead + + alias LinearCli.Linear.{Paginate, Team} + + @document """ + query($first: Int!, $after: String) { + teams(first: $first, after: $after) { + edges { node { #{Team.base_fields()} } cursor } + pageInfo { hasNextPage endCursor } + } + } + """ + + def read(_query, _ecto_query, _opts, _context) do + Paginate.all_pages( + @document, + "teams", + fn after_cursor -> %{"first" => 50, "after" => after_cursor} end, + &Team.from_map/1 + ) + end +end + defmodule LinearCli.Linear.Team.Read.Find do @moduledoc false use Ash.Resource.ManualRead diff --git a/app/lib/linear_cli/linear/user.ex b/app/lib/linear_cli/linear/user.ex index a41b9dd..db0ddef 100644 --- a/app/lib/linear_cli/linear/user.ex +++ b/app/lib/linear_cli/linear/user.ex @@ -15,6 +15,11 @@ defmodule LinearCli.Linear.User do argument :team_id, :string, allow_nil?: false manual LinearCli.Linear.User.Read.ByTeam end + + read :by_team_for_lookup do + argument :team_id, :string, allow_nil?: false + manual LinearCli.Linear.User.Read.ByTeamForLookup + end end attributes do @@ -98,3 +103,53 @@ defmodule LinearCli.Linear.User.Read.ByTeam do end end end + +defmodule LinearCli.Linear.User.Read.ByTeamForLookup do + @moduledoc false + use Ash.Resource.ManualRead + + alias LinearCli.Api + alias LinearCli.Linear.User + + @document """ + query($id: String!, $after: String) { + team(id: $id) { + members(first: 50, after: $after) { + edges { node { #{User.base_fields()} } cursor } + pageInfo { hasNextPage endCursor } + } + } + } + """ + + def read(query, _ecto_query, _opts, _context) do + page(query.arguments.team_id, nil, []) + end + + defp page(team_id, after_cursor, acc) do + case Api.call(@document, %{"id" => team_id, "after" => after_cursor}) do + {:ok, %{"team" => %{"members" => members}}} when is_map(members) -> + nodes = Enum.map(members["edges"] || [], &User.from_map(&1["node"])) + acc = acc ++ nodes + page_info = members["pageInfo"] || %{} + + if page_info["hasNextPage"] == true do + next_cursor = page_info["endCursor"] + + if next_cursor == after_cursor do + {:error, {:non_advancing_cursor, next_cursor}} + else + page(team_id, next_cursor, acc) + end + else + {:ok, acc} + end + + {:ok, _} -> + {:ok, acc} + + error -> + error + end + end +end 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 787ea8a..03644a4 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -14,6 +14,30 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do %{"data" => %{"team" => %{"members" => %{"nodes" => members}}}} end + defp workspace_teams_response(teams) do + %{ + "data" => %{ + "teams" => %{ + "edges" => Enum.map(teams, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + end + + defp workspace_members_page(members, has_next_page, end_cursor) do + %{ + "data" => %{ + "team" => %{ + "members" => %{ + "edges" => Enum.map(members, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => %{"hasNextPage" => has_next_page, "endCursor" => end_cursor} + } + } + } + } + end + describe "issue unassign edge cases" do test "sends a null assignee and confirms each issue in text output" do test_pid = self() @@ -205,6 +229,213 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert output =~ "CRY-1 unassigned" end + test "workspace-wide assignee lookup follows later member pages" do + test_pid = self() + first_page_members = Enum.map(1..50, &%{"id" => "u#{&1}", "name" => "Member #{&1}"}) + late_member = %{"id" => "u-late", "name" => "Late Member"} + team = %{"id" => "t1", "key" => "ENG", "name" => "Engineering", "description" => nil} + + 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, "teams(first: $first") -> + Req.Test.json(conn, workspace_teams_response([team])) + + String.contains?(query, "members(first: 50, after: $after)") -> + case decoded["variables"]["after"] do + nil -> + Req.Test.json(conn, workspace_members_page(first_page_members, true, "member-50")) + + "member-50" -> + Req.Test.json(conn, workspace_members_page([late_member], false, "member-51")) + end + + String.contains?(query, "issues(filter:") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([])) + + String.contains?(query, "issueUpdate") -> + raise "a dry-run lookup 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", + "--assignee", + "Late Member", + "--dry-run" + ]) + end) + + assert output =~ "No issues matched." + assert_received {:filter, %{"assignee" => %{"id" => %{"eq" => "u-late"}}}} + end + + test "workspace-wide assignee partial-match prompts include later member pages" do + test_pid = self() + first_member = %{"id" => "u-first", "name" => "Alice First"} + later_member = %{"id" => "u-later", "name" => "Alice Later"} + team = %{"id" => "t1", "key" => "ENG", "name" => "Engineering", "description" => nil} + + 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, "teams(first: $first") -> + Req.Test.json(conn, workspace_teams_response([team])) + + String.contains?(query, "members(first: 50, after: $after)") -> + case decoded["variables"]["after"] do + nil -> + Req.Test.json(conn, workspace_members_page([first_member], true, "member-1")) + + "member-1" -> + Req.Test.json(conn, workspace_members_page([later_member], false, "member-2")) + end + + 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: "2\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--no-profile", + "--assignee", + "Ali", + "--dry-run" + ]) + end) + + assert output =~ "Alice First" + assert output =~ "Alice Later" + assert output =~ "No issues matched." + assert_received {:filter, %{"assignee" => %{"id" => %{"eq" => "u-later"}}}} + end + + test "workspace-wide assignee lookup deduplicates members shared by teams" do + test_pid = self() + shared = %{"id" => "u-shared", "name" => "Shared Member"} + + teams = [ + %{"id" => "t1", "key" => "ENG", "name" => "Engineering", "description" => nil}, + %{"id" => "t2", "key" => "OPS", "name" => "Operations", "description" => nil} + ] + + 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, "teams(first: $first") -> + Req.Test.json(conn, workspace_teams_response(teams)) + + String.contains?(query, "members(first: 50, after: $after)") -> + Req.Test.json(conn, workspace_members_page([shared], false, "member-1")) + + 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(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--no-profile", + "--assignee", + "Shared Member", + "--dry-run" + ]) + end) + + assert output =~ "No issues matched." + assert_received {:filter, %{"assignee" => %{"id" => %{"eq" => "u-shared"}}}} + end + + test "a later workspace member lookup error stops before issue lookup and mutation" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + first_page_members = Enum.map(1..50, &%{"id" => "u#{&1}", "name" => "Member #{&1}"}) + team = %{"id" => "t1", "key" => "ENG", "name" => "Engineering", "description" => nil} + + 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, "teams(first: $first") -> + Req.Test.json(conn, workspace_teams_response([team])) + + String.contains?(query, "members(first: 50, after: $after)") -> + case decoded["variables"]["after"] do + nil -> + Req.Test.json(conn, workspace_members_page(first_page_members, true, "member-50")) + + "member-50" -> + Plug.Conn.resp(conn, 502, "upstream unavailable") + end + + String.contains?(query, "issues(filter:") -> + raise "a member lookup error must stop before issue lookup" + + String.contains?(query, "issueUpdate") -> + raise "a member lookup error must stop before mutation" + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_stderr(fn stderr -> + LinearCli.CLI.main( + [ + "issue", + "unassign", + "--no-profile", + "--assignee", + "Late Member", + "--yes" + ], + halt, + stderr: stderr + ) + end) + + assert_received {:halted, 88} + assert output =~ "Cannot Continue" + end + test "limits a filtered batch to 100 matches and warns when more exist" do test_pid = self() diff --git a/app/test/linear_cli/cli/commands/issues/read_test.exs b/app/test/linear_cli/cli/commands/issues/read_test.exs index 44be2a5..810e043 100644 --- a/app/test/linear_cli/cli/commands/issues/read_test.exs +++ b/app/test/linear_cli/cli/commands/issues/read_test.exs @@ -36,6 +36,97 @@ defmodule LinearCli.CLI.Commands.Issues.ReadTest do assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p1"}}}} end + test "--project resolves an exact match from a later workspace page" 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, "projects(first: $first") -> + cursor = decoded["variables"]["after"] + send(test_pid, {:project_cursor, cursor}) + + case cursor do + nil -> + Req.Test.json( + conn, + all_projects_page([project_map("p1", "First Page")], true, "project-1") + ) + + "project-1" -> + Req.Test.json( + conn, + all_projects_page([project_map("p2", "Later Page")], false, "project-2") + ) + end + + String.contains?(query, "issues(filter") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "list", "--project", "Later Page"]) + end) + + assert output =~ "CRY-1" + assert_received {:project_cursor, nil} + assert_received {:project_cursor, "project-1"} + assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p2"}}}} + end + + test "--project partial-match prompts include candidates from later workspace pages" 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, "projects(first: $first") -> + case decoded["variables"]["after"] do + nil -> + Req.Test.json( + conn, + all_projects_page([project_map("p1", "Roadmap First")], true, "project-1") + ) + + "project-1" -> + Req.Test.json( + conn, + all_projects_page([project_map("p2", "Roadmap Later")], false, "project-2") + ) + end + + String.contains?(query, "issues(filter") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io([input: "2\n"], fn -> + assert :ok = LinearCli.CLI.main(["issue", "list", "--project", "Roadmap"]) + end) + + assert output =~ "Roadmap First" + assert output =~ "Roadmap Later" + assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p2"}}}} + end + test "--project with --team resolves against team-scoped projects only" do test_pid = self() diff --git a/app/test/linear_cli/linear/paginate_test.exs b/app/test/linear_cli/linear/paginate_test.exs index fc764ab..4b906ac 100644 --- a/app/test/linear_cli/linear/paginate_test.exs +++ b/app/test/linear_cli/linear/paginate_test.exs @@ -42,6 +42,46 @@ defmodule LinearCli.Linear.PaginateTest do refute_receive {:cursor, "c2"} end + test "all_pages follows every page without the default 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..100, true, "c1") + "c1" -> response(101..120, false, "c2") + end + + Req.Test.json(conn, response) + end) + + assert {:ok, values} = Paginate.all_pages("query", "issues", &variables_fun/1, & &1["id"]) + assert length(values) == 120 + assert List.first(values) == 1 + assert List.last(values) == 120 + assert_receive {:cursor, nil} + assert_receive {:cursor, "c1"} + end + + test "all_pages returns a later-page HTTP error" 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_pages("query", "issues", &variables_fun/1, & &1["id"]) + end + test "returns the first page and reports more records without following the cursor" do test_pid = self() diff --git a/app/test/linear_cli/linear/project_test.exs b/app/test/linear_cli/linear/project_test.exs index 2daa617..59d8a0a 100644 --- a/app/test/linear_cli/linear/project_test.exs +++ b/app/test/linear_cli/linear/project_test.exs @@ -30,6 +30,36 @@ defmodule LinearCli.Linear.ProjectTest do assert {:ok, [%Linear.Project{id: "p1", name: "Manhattan"}]} = Linear.projects() end + test "workspace_projects/0 follows every project page for lookup" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => %{"after" => after_cursor}} = Jason.decode!(body) + + {projects, page_info} = + case after_cursor do + nil -> + {[%{"id" => "p1", "name" => "First"}], + %{"hasNextPage" => true, "endCursor" => "page-1"}} + + "page-1" -> + {[%{"id" => "p2", "name" => "Later"}], + %{"hasNextPage" => false, "endCursor" => "page-2"}} + end + + Req.Test.json(conn, %{ + "data" => %{ + "projects" => %{ + "edges" => Enum.map(projects, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => page_info + } + } + }) + end) + + assert {:ok, projects} = Linear.workspace_projects() + assert Enum.map(projects, & &1.id) == ["p1", "p2"] + end + test "projects_by_team/1 paginates only the selected team's projects" do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) diff --git a/app/test/linear_cli/linear/team_test.exs b/app/test/linear_cli/linear/team_test.exs index 06968b1..2a7b077 100644 --- a/app/test/linear_cli/linear/team_test.exs +++ b/app/test/linear_cli/linear/team_test.exs @@ -53,6 +53,36 @@ defmodule LinearCli.Linear.TeamTest do assert {:ok, [%Linear.Team{id: "t1"}, %Linear.Team{id: "t2"}]} = Linear.teams() end + test "workspace_teams/0 follows every team page for lookup" do + team_node = fn id -> %{"id" => id, "key" => id, "name" => id, "description" => nil} end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => %{"after" => after_cursor}} = Jason.decode!(body) + + {teams, page_info} = + case after_cursor do + nil -> + {[team_node.("t1")], %{"hasNextPage" => true, "endCursor" => "c1"}} + + "c1" -> + {[team_node.("t2")], %{"hasNextPage" => false, "endCursor" => "c2"}} + end + + Req.Test.json(conn, %{ + "data" => %{ + "teams" => %{ + "edges" => Enum.map(teams, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => page_info + } + } + }) + end) + + assert {:ok, teams} = Linear.workspace_teams() + assert Enum.map(teams, & &1.id) == ["t1", "t2"] + end + test "find_team/1 looks up a single team by id (Ruby: BaseModel::ClassMethods#find)" do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) diff --git a/app/test/linear_cli/linear/user_test.exs b/app/test/linear_cli/linear/user_test.exs index 539be77..9eac608 100644 --- a/app/test/linear_cli/linear/user_test.exs +++ b/app/test/linear_cli/linear/user_test.exs @@ -47,6 +47,41 @@ defmodule LinearCli.Linear.UserTest do assert {:ok, []} = Linear.team_members("t1") end + test "workspace_team_members/1 follows every member page for lookup" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + after_cursor = decoded["variables"]["after"] + assert query =~ "members(first: 50, after: $after)" + + {members, page_info} = + case after_cursor do + nil -> + {[%{"id" => "u1", "name" => "First", "email" => "first@example.com"}], + %{"hasNextPage" => true, "endCursor" => "member-1"}} + + "member-1" -> + {[%{"id" => "u2", "name" => "Later", "email" => "later@example.com"}], + %{"hasNextPage" => false, "endCursor" => "member-2"}} + end + + Req.Test.json(conn, %{ + "data" => %{ + "team" => %{ + "members" => %{ + "edges" => Enum.map(members, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => page_info + } + } + } + }) + end) + + assert {:ok, members} = Linear.workspace_team_members("t1") + assert Enum.map(members, & &1.id) == ["u1", "u2"] + end + test "team_members/1 propagates API errors" do Req.Test.stub(LinearCli.Api, fn conn -> Req.Test.json(conn, %{"errors" => [%{"message" => "Unauthorized"}]}) diff --git a/app/test/support/issue_commands_helpers.ex b/app/test/support/issue_commands_helpers.ex index 95d25ce..9357fdf 100644 --- a/app/test/support/issue_commands_helpers.ex +++ b/app/test/support/issue_commands_helpers.ex @@ -130,6 +130,17 @@ defmodule LinearCli.CLI.IssueCommandsHelpers do } end + def all_projects_page(projects, has_next_page, end_cursor) do + %{ + "data" => %{ + "projects" => %{ + "edges" => Enum.map(projects, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => %{"hasNextPage" => has_next_page, "endCursor" => end_cursor} + } + } + } + end + def issues_response(issues) do %{ "data" => %{ diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 6798e57..921212e 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -300,6 +300,20 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `Linear.User.Read.Me` | `{ viewer { ... } }` — `viewer` query with `User.fields_with_teams/0` +| `User` +| `team_members` +| `:by_team` +| read +| `Linear.User.Read.ByTeam` +| `team(id: $id) { members(first: 50) { ... } }` — one page for team-scoped assignment + +| `User` +| `workspace_team_members` +| `:by_team_for_lookup` +| read +| `Linear.User.Read.ByTeamForLookup` +| `team(id: $id) { members(first: 50, after: $after) { ... } }` — follows every page for workspace-wide matching + | `Team` | `teams` | `:all` @@ -307,6 +321,13 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `Linear.Team.Read.All` | `teams(first:, after:)` — paginated with `Team.base_fields/0` +| `Team` +| `workspace_teams` +| `:all_for_lookup` +| read +| `Linear.Team.Read.AllForLookup` +| `teams(first:, after:)` — follows every page for workspace-wide member matching + | `Team` | `my_teams` | `:mine` @@ -328,6 +349,13 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `Linear.Project.Read.All` | `projects(first:, after:)` — paginated with `Project.base_fields/0` +| `Project` +| `workspace_projects` +| `:all_for_lookup` +| read +| `Linear.Project.Read.AllForLookup` +| `projects(first:, after:)` — follows every page for workspace-wide project matching + | `Project` | `my_projects` | `:mine` @@ -554,6 +582,9 @@ and returns the common issue-list arguments for the domain. Filter-only 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. +Workspace project lookup uses `Project.workspace_projects`. Workspace assignee +lookup follows every page of every accessible team and deduplicates members by +user ID. Team-scoped lookup keeps the existing one-page member action. === `LinearCli.Linear.Paginate` @@ -563,17 +594,25 @@ 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. It returns an error for a repeated continuation cursor instead of looping forever. +`all_pages/4` follows every page without a record limit. Lookup-only actions use +this variant. The bounded `all/5` path remains unchanged for issue operations. `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 +* `Team.Read.AllForLookup` — complete workspace team lookup * `Project.Read.All` — `projects(first:, after:)` paginated query +* `Project.Read.AllForLookup` — complete workspace project lookup * `Issue.Read.List` — `issues(filter:, first:, after:)` paginated list (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 +`User.Read.ByTeamForLookup` follows the nested member connection with the same +cursor advancement guard. It returns every member for one team before the CLI +deduplicates the workspace-wide candidate set. + == Maintenance contract *This document must be updated in the same change as any of the following:* From 2f35e1272e09bb86d924c6af7ba2dac32a7e3634 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Mon, 28 Sep 2026 20:57:54 -0400 Subject: [PATCH 3/4] fix(issues): harden workspace lookup pagination --- .../linear_cli/cli/commands/issues/read.ex | 2 +- app/lib/linear_cli/linear/user.ex | 42 +++-- .../cli/commands/issues/mutations_test.exs | 173 +++++++++++++++++- .../cli/commands/issues/read_test.exs | 25 ++- app/test/linear_cli/linear/user_test.exs | 30 +++ 5 files changed, 246 insertions(+), 26 deletions(-) diff --git a/app/lib/linear_cli/cli/commands/issues/read.ex b/app/lib/linear_cli/cli/commands/issues/read.ex index e0f91e6..e0f69c9 100644 --- a/app/lib/linear_cli/cli/commands/issues/read.ex +++ b/app/lib/linear_cli/cli/commands/issues/read.ex @@ -18,7 +18,7 @@ defmodule LinearCli.CLI.Commands.Issues.Read do `--project`/`-p` resolution is team-scoped when `--team` is given (or the active profile supplies a team) - it searches that team's projects via `projects_by_team`. Without a team context it falls back to all workspace - projects (`Project.workspace_projects`). Prompts interactively when the search is + projects (`Linear.workspace_projects`). Prompts interactively when the search is ambiguous or omitted-but-requested (`-p -`). Only resolved at all when `--project` was actually given (or `LinearCli.Profiles.default_project/0` supplies one) - unlike `issue create`/`issue update`, a bare `issue list` diff --git a/app/lib/linear_cli/linear/user.ex b/app/lib/linear_cli/linear/user.ex index db0ddef..a6eac2f 100644 --- a/app/lib/linear_cli/linear/user.ex +++ b/app/lib/linear_cli/linear/user.ex @@ -129,27 +129,37 @@ defmodule LinearCli.Linear.User.Read.ByTeamForLookup do defp page(team_id, after_cursor, acc) do case Api.call(@document, %{"id" => team_id, "after" => after_cursor}) do {:ok, %{"team" => %{"members" => members}}} when is_map(members) -> - nodes = Enum.map(members["edges"] || [], &User.from_map(&1["node"])) - acc = acc ++ nodes - page_info = members["pageInfo"] || %{} - - if page_info["hasNextPage"] == true do - next_cursor = page_info["endCursor"] - - if next_cursor == after_cursor do - {:error, {:non_advancing_cursor, next_cursor}} - else - page(team_id, next_cursor, acc) - end - else - {:ok, acc} - end + continue_page(members, team_id, after_cursor, acc) - {:ok, _} -> + {:ok, _response} when is_nil(after_cursor) -> {:ok, acc} + {:ok, response} -> + {:error, {:unexpected_response, response}} + + {:error, {:http_error, status, _body}} -> + {:error, {:http_error, status}} + error -> error end end + + defp continue_page(members, team_id, after_cursor, acc) do + nodes = Enum.map(members["edges"] || [], &User.from_map(&1["node"])) + acc = acc ++ nodes + page_info = members["pageInfo"] || %{} + + if page_info["hasNextPage"] == true do + next_cursor = page_info["endCursor"] + + if next_cursor == after_cursor do + {:error, {:non_advancing_cursor, next_cursor}} + else + page(team_id, next_cursor, acc) + end + else + {:ok, acc} + end + end end 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 03644a4..5be1332 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -15,11 +15,15 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do end defp workspace_teams_response(teams) do + workspace_teams_page(teams, false, nil) + end + + defp workspace_teams_page(teams, has_next_page, end_cursor) do %{ "data" => %{ "teams" => %{ "edges" => Enum.map(teams, &%{"node" => &1, "cursor" => &1["id"]}), - "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + "pageInfo" => %{"hasNextPage" => has_next_page, "endCursor" => end_cursor} } } } @@ -282,6 +286,93 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert_received {:filter, %{"assignee" => %{"id" => %{"eq" => "u-late"}}}} end + test "workspace-wide assignee lookup follows teams beyond the first 100" do + test_pid = self() + + first_page_teams = + Enum.map(1..50, fn number -> + %{ + "id" => "t#{number}", + "key" => "T#{number}", + "name" => "Team #{number}", + "description" => nil + } + end) + + second_page_teams = + Enum.map(51..100, fn number -> + %{ + "id" => "t#{number}", + "key" => "T#{number}", + "name" => "Team #{number}", + "description" => nil + } + end) + + late_team = %{ + "id" => "t101", + "key" => "T101", + "name" => "Team 101", + "description" => nil + } + + late_member = %{"id" => "u-late-team", "name" => "Late Team Member"} + + 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, "teams(first: $first") -> + case decoded["variables"]["after"] do + nil -> + Req.Test.json(conn, workspace_teams_page(first_page_teams, true, "team-50")) + + "team-50" -> + Req.Test.json(conn, workspace_teams_page(second_page_teams, true, "team-100")) + + "team-100" -> + Req.Test.json(conn, workspace_teams_page([late_team], false, "team-101")) + end + + String.contains?(query, "members(first: 50, after: $after)") -> + case decoded["variables"]["id"] do + "t101" -> + send(test_pid, {:late_member_team, "t101"}) + Req.Test.json(conn, workspace_members_page([late_member], false, "member-101")) + + _team_id -> + Req.Test.json(conn, workspace_members_page([], false, nil)) + end + + 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(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "unassign", + "--no-profile", + "--assignee", + "Late Team Member", + "--dry-run" + ]) + end) + + assert output =~ "No issues matched." + assert_received {:late_member_team, "t101"} + assert_received {:filter, %{"assignee" => %{"id" => %{"eq" => "u-late-team"}}}} + end + test "workspace-wide assignee partial-match prompts include later member pages" do test_pid = self() first_member = %{"id" => "u-first", "name" => "Alice First"} @@ -402,7 +493,7 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do Req.Test.json(conn, workspace_members_page(first_page_members, true, "member-50")) "member-50" -> - Plug.Conn.resp(conn, 502, "upstream unavailable") + Plug.Conn.resp(conn, 401, "upstream unavailable") end String.contains?(query, "issues(filter:") -> @@ -432,8 +523,11 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do ) end) - assert_received {:halted, 88} - assert output =~ "Cannot Continue" + assert_received {:halted, 77} + assert output =~ "Linear API authentication failed (HTTP 401)." + assert output =~ "Authentication error, cannot continue" + refute output =~ "What the heck is this?" + refute output =~ "upstream unavailable" end test "limits a filtered batch to 100 matches and warns when more exist" do @@ -1050,6 +1144,77 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert output =~ "No project found matching Missing Project" end + test "unassign resolves an exact workspace project after the first 100" 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, "projects(first: $first") -> + case decoded["variables"]["after"] do + nil -> + Req.Test.json( + conn, + all_projects_page( + Enum.map(1..50, &project_map("p#{&1}", "First Project #{&1}")), + true, + "project-50" + ) + ) + + "project-50" -> + Req.Test.json( + conn, + all_projects_page( + Enum.map(51..100, &project_map("p#{&1}", "Middle Project #{&1}")), + true, + "project-100" + ) + ) + + "project-100" -> + Req.Test.json( + conn, + all_projects_page( + [project_map("p101", "Later Project")], + false, + "project-101" + ) + ) + end + + String.contains?(query, "issues(filter:") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([])) + + String.contains?(query, "issueUpdate") -> + raise "a dry-run lookup 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", + "--project", + "Later Project", + "--dry-run" + ]) + end) + + assert output =~ "No issues matched." + assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p101"}}}} + 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) diff --git a/app/test/linear_cli/cli/commands/issues/read_test.exs b/app/test/linear_cli/cli/commands/issues/read_test.exs index 810e043..47045d8 100644 --- a/app/test/linear_cli/cli/commands/issues/read_test.exs +++ b/app/test/linear_cli/cli/commands/issues/read_test.exs @@ -53,13 +53,27 @@ defmodule LinearCli.CLI.Commands.Issues.ReadTest do nil -> Req.Test.json( conn, - all_projects_page([project_map("p1", "First Page")], true, "project-1") + all_projects_page( + Enum.map(1..50, &project_map("p#{&1}", "First Page #{&1}")), + true, + "project-50" + ) ) - "project-1" -> + "project-50" -> + Req.Test.json( + conn, + all_projects_page( + Enum.map(51..100, &project_map("p#{&1}", "Middle Page #{&1}")), + true, + "project-100" + ) + ) + + "project-100" -> Req.Test.json( conn, - all_projects_page([project_map("p2", "Later Page")], false, "project-2") + all_projects_page([project_map("p101", "Later Page")], false, "project-101") ) end @@ -80,8 +94,9 @@ defmodule LinearCli.CLI.Commands.Issues.ReadTest do assert output =~ "CRY-1" assert_received {:project_cursor, nil} - assert_received {:project_cursor, "project-1"} - assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p2"}}}} + assert_received {:project_cursor, "project-50"} + assert_received {:project_cursor, "project-100"} + assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p101"}}}} end test "--project partial-match prompts include candidates from later workspace pages" do diff --git a/app/test/linear_cli/linear/user_test.exs b/app/test/linear_cli/linear/user_test.exs index 9eac608..ace798b 100644 --- a/app/test/linear_cli/linear/user_test.exs +++ b/app/test/linear_cli/linear/user_test.exs @@ -82,6 +82,36 @@ defmodule LinearCli.Linear.UserTest do assert Enum.map(members, & &1.id) == ["u1", "u2"] end + test "workspace_team_members/1 reports a malformed later page" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + after_cursor = Jason.decode!(body)["variables"]["after"] + + response = + case after_cursor do + nil -> + %{ + "data" => %{ + "team" => %{ + "members" => %{ + "edges" => [], + "pageInfo" => %{"hasNextPage" => true, "endCursor" => "member-1"} + } + } + } + } + + "member-1" -> + %{"data" => %{"team" => nil}} + end + + Req.Test.json(conn, response) + end) + + assert {:error, %Ash.Error.Unknown{errors: [%{value: [{:unexpected_response, _}]}]}} = + Linear.workspace_team_members("t1") + end + test "team_members/1 propagates API errors" do Req.Test.stub(LinearCli.Api, fn conn -> Req.Test.json(conn, %{"errors" => [%{"message" => "Unauthorized"}]}) From 148152886aaa62044638b5eafffd71b8529ad04f Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Mon, 28 Sep 2026 21:04:05 -0400 Subject: [PATCH 4/4] test(issues): cover workspace lookup HTTP errors --- .../cli/commands/issues/mutations_test.exs | 107 ++++++++++-------- 1 file changed, 59 insertions(+), 48 deletions(-) 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 5be1332..4250972 100644 --- a/app/test/linear_cli/cli/commands/issues/mutations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/mutations_test.exs @@ -42,6 +42,56 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do } end + defp run_later_member_lookup_error(status, test_pid) do + halt = fn code -> send(test_pid, {:halted, code}) end + first_page_members = Enum.map(1..50, &%{"id" => "u#{&1}", "name" => "Member #{&1}"}) + team = %{"id" => "t1", "key" => "ENG", "name" => "Engineering", "description" => nil} + + 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, "teams(first: $first") -> + Req.Test.json(conn, workspace_teams_response([team])) + + String.contains?(query, "members(first: 50, after: $after)") -> + case decoded["variables"]["after"] do + nil -> + Req.Test.json(conn, workspace_members_page(first_page_members, true, "member-50")) + + "member-50" -> + Plug.Conn.resp(conn, status, "upstream unavailable") + end + + String.contains?(query, "issues(filter:") -> + raise "a member lookup error must stop before issue lookup" + + String.contains?(query, "issueUpdate") -> + raise "a member lookup error must stop before mutation" + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_stderr(fn stderr -> + LinearCli.CLI.main( + [ + "issue", + "unassign", + "--no-profile", + "--assignee", + "Late Member", + "--yes" + ], + halt, + stderr: stderr + ) + end) + end + describe "issue unassign edge cases" do test "sends a null assignee and confirms each issue in text output" do test_pid = self() @@ -472,56 +522,17 @@ defmodule LinearCli.CLI.Commands.Issues.MutationsTest do assert_received {:filter, %{"assignee" => %{"id" => %{"eq" => "u-shared"}}}} end - test "a later workspace member lookup error stops before issue lookup and mutation" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - first_page_members = Enum.map(1..50, &%{"id" => "u#{&1}", "name" => "Member #{&1}"}) - team = %{"id" => "t1", "key" => "ENG", "name" => "Engineering", "description" => nil} - - 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, "teams(first: $first") -> - Req.Test.json(conn, workspace_teams_response([team])) - - String.contains?(query, "members(first: 50, after: $after)") -> - case decoded["variables"]["after"] do - nil -> - Req.Test.json(conn, workspace_members_page(first_page_members, true, "member-50")) + test "a later workspace member HTTP error stops before issue lookup and mutation" do + output = run_later_member_lookup_error(502, self()) - "member-50" -> - Plug.Conn.resp(conn, 401, "upstream unavailable") - end - - String.contains?(query, "issues(filter:") -> - raise "a member lookup error must stop before issue lookup" - - String.contains?(query, "issueUpdate") -> - raise "a member lookup error must stop before mutation" - - true -> - raise "no stub matched query: #{query}" - end - end) + assert_received {:halted, 88} + assert output =~ "Linear API returned HTTP 502." + refute output =~ "What the heck is this?" + refute output =~ "upstream unavailable" + end - output = - capture_stderr(fn stderr -> - LinearCli.CLI.main( - [ - "issue", - "unassign", - "--no-profile", - "--assignee", - "Late Member", - "--yes" - ], - halt, - stderr: stderr - ) - end) + test "a later workspace member authentication error uses the auth handler" do + output = run_later_member_lookup_error(401, self()) assert_received {:halted, 77} assert output =~ "Linear API authentication failed (HTTP 401)."