diff --git a/AGENTS.md b/AGENTS.md index 4095328..cb64a3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,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 7815be2..47178c0 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -289,17 +289,38 @@ $ 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 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. +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. 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 +matching issues without changing them. +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. 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.ex b/app/lib/linear_cli/cli.ex index 312e137..b45bbff 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -909,8 +909,62 @@ 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" + ], + 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 assignee name (exact or partial match)" + ], + 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..e23d64d --- /dev/null +++ b/app/lib/linear_cli/cli/commands/issues/filter.ex @@ -0,0 +1,197 @@ +defmodule LinearCli.CLI.Commands.Issues.Filter do + @moduledoc false + + 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 = Map.get(options, :team) || profile_default(no_profile, &Profiles.default_team/0) + + project = + Map.get(options, :project) || profile_default(no_profile, &Profiles.default_project/0) + + {team, project} + 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_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), + 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 != []) + } + 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_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 4dc0c7d..86913ca 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,141 @@ 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, + resolve_assignee: true, + assigned_only: true + ), + {: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 + + 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 + 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 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 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"}) + 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 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!"}} + + 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/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..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 diff --git a/app/lib/linear_cli/linear/issue.ex b/app/lib/linear_cli/linear/issue.ex index 6e4460f..9811074 100644 --- a/app/lib/linear_cli/linear/issue.ex +++ b/app/lib/linear_cli/linear/issue.ex @@ -11,6 +11,8 @@ defmodule LinearCli.Linear.Issue do argument :ids, {:array, :string}, default: [] argument :mine, :boolean, default: true argument :unassigned, :boolean, default: false + 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 @@ -21,6 +23,11 @@ defmodule LinearCli.Linear.Issue do 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 @@ -184,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 @@ -250,6 +270,18 @@ defmodule LinearCli.Linear.Issue.Read.List do document, "issues", fn after_cursor -> %{"filter" => filter, "first" => 50, "after" => after_cursor} end, + &Issue.from_map/1, + 100 + ) + 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 @@ -299,6 +331,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, %{unassigned: true}) do Map.put(filter, "assignee", %{"null" => true}) end @@ -307,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 @@ -361,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 c369b68..f7e3849 100644 --- a/app/lib/linear_cli/linear/paginate.ex +++ b/app/lib/linear_cli/linear/paginate.ex @@ -22,16 +22,41 @@ 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}} <- 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 +64,10 @@ defmodule LinearCli.Linear.Paginate do end end + defp reached_limit?(acc, max), do: length(acc) >= max + + 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/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 1c292fa..53e717c 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() @@ -126,7 +130,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 +140,540 @@ 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, "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()})])) + + 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", + "--team", + "ENG", + "--assignee", + "Ada", + "--yes" + ]) + end) + + 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}} + refute output =~ "Unassign 1 issue(s)?" + assert output =~ "CRY-1 unassigned" + end + + test "limits a filtered batch to 100 matches and warns when more exist" 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 -> + 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) + + 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", + "--no-profile", + "--team", + "ENG", + "--yes" + ]) + end) + + assert_received {:page_requested, nil} + 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..100, [], fn _number, acc -> + receive do + {:updated, identifier} -> [identifier | acc] + after + 1_000 -> flunk("expected all 100 issue updates") + end + end) + + assert length(updated_ids) == 100 + 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() + + 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", + "--yes", + "--team", + "ENG", + "--state", + "started", + "--status", + "Human Review", + "--labels", + "Bug,Feature" + ]) + end) + + 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"} + + 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 "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 "reports a first-page read error without mutating" 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:") -> + Plug.Conn.resp(conn, 502, "upstream unavailable") + + String.contains?(query, "issueUpdate") -> + raise "no update is allowed after a first-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..d004645 100644 --- a/app/test/linear_cli/cli/profile_defaults_test.exs +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -444,6 +444,80 @@ 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, "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")}}) + + 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, yes: true, dry_run: 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"] == %{"id" => %{"eq" => "u1"}} + 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/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/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 new file mode 100644 index 0000000..fc764ab --- /dev/null +++ b/app/test/linear_cli/linear/paginate_test.exs @@ -0,0 +1,79 @@ +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 "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 "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"]) + 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..6798e57 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,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; or `issue(id:)` per id (full, fanned concurrently) + 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` @@ -532,19 +543,36 @@ 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 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` `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. 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) +* `Issue.Actions.ListFirstPage` — wraps `Issue.Read.List.first_page/1` in an + Ash generic action for the filter-only unassign path == Maintenance contract