From 234491b2d1f9a399b929f33a519d0a0808b819c0 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 13:13:19 -0400 Subject: [PATCH 1/3] refactor(cli): extract move and relation commands into focused modules Completes EXT-54 (phase 18): extracts `issue_move` and `issue_relation_{list,add,remove}` from the monolithic `LinearCli.CLI.Commands` module into `Issues.Move` and `Issues.Relations`. Deletes the now-empty `commands.ex` and the monolithic `issue_commands_test.exs`, replacing them with `move_test.exs` and `relations_test.exs`. Updates `cli.ex` dispatch aliases accordingly and updates the AGENTS.md module map. Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 26 +- app/lib/linear_cli/cli.ex | 11 +- .../linear_cli/cli/commands/issues/move.ex | 226 +++ .../issues/relations.ex} | 244 +-- .../cli/commands/issues/move_test.exs | 749 ++++++++ .../cli/commands/issues/relations_test.exs | 841 +++++++++ .../linear_cli/cli/issue_commands_test.exs | 1593 ----------------- 7 files changed, 1847 insertions(+), 1843 deletions(-) create mode 100644 app/lib/linear_cli/cli/commands/issues/move.ex rename app/lib/linear_cli/cli/{commands.ex => commands/issues/relations.ex} (61%) create mode 100644 app/test/linear_cli/cli/commands/issues/move_test.exs create mode 100644 app/test/linear_cli/cli/commands/issues/relations_test.exs delete mode 100644 app/test/linear_cli/cli/issue_commands_test.exs diff --git a/AGENTS.md b/AGENTS.md index 41f063a..85af157 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,13 +80,19 @@ Supporting directories (not Mix projects): The app is organized in layers: ``` -LinearCli.CLI — Entry point; Optimus argument parsing and dispatch - CLI.Commands — One function per subcommand; formats and prints output - CLI.IssueHelpers — Issue-lookup logic shared across subcommands - CLI.WhatFor — Interactive prompts (team, project, label selection) - CLI.Display — Output formatting helpers - CLI.Projects — Project-specific prompt/resolution helpers - CLI.Prompt — Low-level readline-style prompt wrapper +LinearCli.CLI — Entry point; Optimus argument parsing and dispatch + CLI.Commands.System — system/profile/team/project subcommands + CLI.Commands.Issues.Read — issue read/list/show subcommands + CLI.Commands.Issues.Create — issue create subcommand + CLI.Commands.Issues.Development — issue branch/PR subcommands + CLI.Commands.Issues.Mutations — issue status/assign/label/comment subcommands + CLI.Commands.Issues.Move — issue move subcommand + CLI.Commands.Issues.Relations — issue relation list/add/remove subcommands + CLI.Issue.Identifiers — issue ID expansion shared across subcommands + CLI.WhatFor — Interactive prompts (team, project, label selection) + CLI.Display — Output formatting helpers + CLI.Projects — Project-specific prompt/resolution helpers + CLI.Prompt — Low-level readline-style prompt wrapper LinearCli.Linear — Ash domain; all Linear API resources and actions Linear.Issue/Team/... — Ash resources (actions are manual GraphQL calls) @@ -105,11 +111,11 @@ LinearCli.ObanRepo — Runtime adapter selector (SQLite or Postgres) LinearCli.Application — OTP Application; starts interactive or daemon mode ``` -Data flows top-to-bottom through these layers: `CLI.Commands` calls the +Data flows top-to-bottom through these layers: `CLI.Commands.*` modules call the `LinearCli.Linear` domain code interface; the domain's Ash resources call `LinearCli.Api`; `LinearCli.Api` calls the Linear GraphQL API. `Profiles` -and `Favorites` are side-channels consulted by `CLI.Commands` and -`CLI.IssueHelpers` for defaults, not part of the main API data flow. +and `Favorites` are side-channels consulted by `CLI.Commands.*` modules +for defaults, not part of the main API data flow. The daemon mode (started when `LINEAR_CLI_DAEMON=true`) runs `LinearCli.Rollover.Worker` via Oban on a monthly cron schedule. The diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 846fb4a..27fb222 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -7,8 +7,7 @@ defmodule LinearCli.CLI do handling) plus each subcommand's own `commands/**` file for its flags. """ - alias LinearCli.CLI.Commands - alias LinearCli.CLI.Commands.Issues.{Create, Development, Mutations, Read} + alias LinearCli.CLI.Commands.Issues.{Create, Development, Move, Mutations, Read, Relations} alias LinearCli.CLI.Commands.{Profiles, Projects, Teams} alias LinearCli.CLI.Commands.System, as: SystemCmds @@ -238,7 +237,7 @@ defmodule LinearCli.CLI do do: run(&Development.issue_develop/1, result, halt) defp dispatch([:issue, :pr], result, halt), do: run(&Development.issue_pr/1, result, halt) - defp dispatch([:issue, :move], result, halt), do: run(&Commands.issue_move/1, result, halt) + defp dispatch([:issue, :move], result, halt), do: run(&Move.issue_move/1, result, halt) defp dispatch([:issue, :comment], result, halt), do: run(&Mutations.issue_comment/1, result, halt) @@ -248,13 +247,13 @@ defmodule LinearCli.CLI do defp dispatch([:issue, :update], result, halt), do: run(&Mutations.issue_update/1, result, halt) defp dispatch([:issue, :relation, :list], result, halt), - do: run(&Commands.issue_relation_list/1, result, halt) + do: run(&Relations.issue_relation_list/1, result, halt) defp dispatch([:issue, :relation, :add], result, halt), - do: run(&Commands.issue_relation_add/1, result, halt) + do: run(&Relations.issue_relation_add/1, result, halt) defp dispatch([:issue, :relation, :remove], result, halt), - do: run(&Commands.issue_relation_remove/1, result, halt) + do: run(&Relations.issue_relation_remove/1, result, halt) # A valid subcommand path that stops short of a leaf (e.g. `lc project` # with nothing after it) - Optimus itself doesn't require reaching a leaf, diff --git a/app/lib/linear_cli/cli/commands/issues/move.ex b/app/lib/linear_cli/cli/commands/issues/move.ex new file mode 100644 index 0000000..2cb0ff0 --- /dev/null +++ b/app/lib/linear_cli/cli/commands/issues/move.ex @@ -0,0 +1,226 @@ +defmodule LinearCli.CLI.Commands.Issues.Move do + @moduledoc """ + Issue move command: moves issues to a target project by ID or in bulk. + Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/move.rb. + """ + + alias LinearCli.CLI.{Display, Projects, Prompt, WhatFor} + alias LinearCli.CLI.Issue.Identifiers + alias LinearCli.{Linear, Profiles} + + @max_concurrent_issue_updates 20 + + @doc """ + Moves issues to a target project. + + Two modes: + - **ID-based** (EXT-9): `ISSUE_ID... --project P [--team T]` — moves the + listed issues to the named project, resolved per-issue from the issue's + own team or the given `--team`. Concurrent apply, same pattern as + `issue_status/1`. + - **Bulk project-to-project** (Phase 12): `--from P --to P [--team T]` — + lists all open issues (or all with `--all`) from the source project and + fans out mutations to the target project concurrently. + + With `--dry-run`, prints the planned moves without mutating. + Without `--yes`, asks for confirmation before applying. + """ + @spec issue_move(Optimus.ParseResult.t()) :: :ok | {:error, term()} + def issue_move(%{unknown: issue_ids, options: options, flags: flags}) do + cond do + options.from && options.to -> + move_issues_by_project(options, flags) + + options.from || options.to -> + {:error, + {:smells_bad, "--from and --to must both be given for bulk project-to-project mode"}} + + true -> + move_issues_by_id(issue_ids, options, flags) + end + end + + defp move_issues_by_id(issue_ids, options, flags) do + with :ok <- validate_issue_ids(issue_ids), + {:ok, issues} <- + Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), + {:ok, project} <- resolve_move_project(issues, options) do + print_move_plan(issues, project, options.output) + execute_moves_if_confirmed(issues, project, flags, options.output) + end + end + + defp resolve_move_project(issues, options) do + with {:ok, tid} <- resolve_move_team_id(options.team || Profiles.default_team(), issues), + {:ok, projects} <- Linear.projects_by_team(tid, %{search: options.project}) do + project_result(Projects.project_for(projects, options.project), options.project) + end + end + + defp project_result(nil, search), + do: {:error, {:smells_bad, "No project found matching #{inspect(search)}"}} + + defp project_result(project, _search), do: {:ok, project} + + defp resolve_move_team_id(nil, issues), do: {:ok, hd(issues).team.id} + + defp resolve_move_team_id(key, _issues) do + with {:ok, team} <- Linear.find_team(key), do: {:ok, team.id} + end + + defp execute_moves_if_confirmed(_issues, _project, %{dry_run: true}, _output), do: :ok + + defp execute_moves_if_confirmed(issues, project, %{yes: true}, output), + do: apply_moves(issues, project, output) + + defp execute_moves_if_confirmed(issues, project, _flags, output) do + if Prompt.yes?("Proceed with move?"), + do: apply_moves(issues, project, output), + else: Prompt.warn("Move cancelled") + end + + defp print_move_plan(issues, project, output) when output != "json" do + Enum.each(issues, fn issue -> + Prompt.say("#{issue.identifier} -> #{project.name}") + end) + end + + defp print_move_plan(_issues, _project, _output), do: :ok + + defp apply_moves(issues, project, output) do + issues + |> Task.async_stream( + fn issue -> apply_move(issue, project) end, + max_concurrency: min(length(issues), @max_concurrent_issue_updates), + ordered: true, + timeout: 30_000 + ) + |> Enum.reduce_while({:ok, []}, fn + {:ok, {:ok, updated}}, {:ok, acc} -> {:cont, {:ok, [updated | acc]}} + {:ok, {:error, reason}}, _acc -> {:halt, {:error, reason}} + {:exit, reason}, _acc -> {:halt, {:error, {:task_exit, reason}}} + end) + |> display_moves_result(project, output) + end + + defp display_moves_result({:ok, updated_issues}, project, output) do + updated_issues = Enum.reverse(updated_issues) + Display.show(one_or_many(updated_issues), %{output: output}) + print_move_results(updated_issues, project, output) + :ok + end + + defp display_moves_result(error, _project, _output), do: error + + defp print_move_results(updated_issues, project, output) when output != "json" do + Enum.each(updated_issues, fn updated -> + Prompt.ok("#{updated.identifier} moved to #{project.name}") + end) + end + + defp print_move_results(_updated_issues, _project, _output), do: :ok + + defp apply_move(issue, project) do + Linear.attach_issue_to_project(issue, project.id) + end + + defp validate_issue_ids([]), do: {:error, {:smells_bad, "No issue IDs provided!"}} + defp validate_issue_ids(_issue_ids), do: :ok + + @uuid_regex ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + + defp move_issues_by_project(options, flags) do + team_fn = fn -> WhatFor.team_for(options.team || Profiles.default_team()) end + + with {:ok, source} <- resolve_bulk_project(options.from, team_fn), + {:ok, target} <- resolve_bulk_project(options.to, team_fn), + :ok <- guard_different_projects(source, target), + {:ok, issues} <- Linear.issues(%{project_id: source.id, mine: false, all: flags.all}) do + cond do + issues == [] -> + label = if flags.all, do: "issues", else: "open issues" + Prompt.ok("No #{label} in #{source.name} to move") + :ok + + flags.dry_run -> + Display.show(one_or_many(issues), %{output: options.output}) + Prompt.ok("Would move #{length(issues)} issue(s) from #{source.name} to #{target.name}") + :ok + + not flags.yes and + not Prompt.yes?( + "Move #{length(issues)} issue(s) from #{source.name} to #{target.name}?" + ) -> + Prompt.warn("Move cancelled") + + true -> + with {:ok, pairs} <- apply_project_moves(issues, target) do + show_move_results(pairs, source, target, options.output) + end + end + end + end + + defp resolve_bulk_project(value, team_fn) do + if Regex.match?(@uuid_regex, value) do + short_name = String.slice(value, 0, 8) <> "…" + {:ok, struct(LinearCli.Linear.Project, %{id: value, name: short_name})} + else + team = team_fn.() + + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: value}), + project when not is_nil(project) <- Projects.project_for(projects, value) do + {:ok, project} + else + nil -> {:error, {:smells_bad, "No project found matching #{value}"}} + {:error, reason} -> {:error, reason} + end + end + end + + defp guard_different_projects(%{id: id}, %{id: id}), + do: {:error, {:smells_bad, "source and target are the same project"}} + + defp guard_different_projects(_source, _target), do: :ok + + defp apply_project_moves(issues, target) do + issues + |> Task.async_stream( + fn issue -> + case Linear.attach_issue_to_project(issue, target.id) do + {:ok, updated} -> {:ok, {issue, updated}} + {:error, reason} -> {:error, reason} + end + end, + max_concurrency: min(length(issues), @max_concurrent_issue_updates), + ordered: true, + timeout: 30_000 + ) + |> Enum.reduce_while({:ok, []}, fn + {:ok, {:ok, pair}}, {:ok, acc} -> {:cont, {:ok, [pair | acc]}} + {:ok, {:error, reason}}, {:ok, _acc} -> {:halt, {:error, reason}} + {:exit, reason}, {:ok, _acc} -> {:halt, {:error, {:task_exit, reason}}} + end) + |> then(fn + {:ok, results} -> {:ok, Enum.reverse(results)} + error -> error + end) + end + + defp show_move_results(pairs, source, target, output) do + if output == "json" do + Display.show(one_or_many(Enum.map(pairs, &elem(&1, 1))), %{output: "json"}) + else + Enum.each(pairs, fn {orig, _updated} -> + Prompt.ok("#{orig.identifier} moved to #{target.name}") + end) + + Prompt.ok("Moved #{length(pairs)} issue(s) from #{source.name} to #{target.name}") + end + + :ok + end + + defp one_or_many([one]), do: one + defp one_or_many(many), do: many +end diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands/issues/relations.ex similarity index 61% rename from app/lib/linear_cli/cli/commands.ex rename to app/lib/linear_cli/cli/commands/issues/relations.ex index c971dae..9a5f4c3 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands/issues/relations.ex @@ -1,236 +1,12 @@ -defmodule LinearCli.CLI.Commands do +defmodule LinearCli.CLI.Commands.Issues.Relations do @moduledoc """ - Remaining issue command implementations pending extraction in EXT-54: move - and relation commands. Fetch via `LinearCli.Linear`, display the result. - Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/**. - - All other command families live in their own focused modules: - `LinearCli.CLI.Commands.System`, `LinearCli.CLI.Commands.Teams`, - `LinearCli.CLI.Commands.Projects`, `LinearCli.CLI.Commands.Profiles`, - `LinearCli.CLI.Commands.Issues.Read`, `LinearCli.CLI.Commands.Issues.Create`, - `LinearCli.CLI.Commands.Issues.Development`, and - `LinearCli.CLI.Commands.Issues.Mutations`. + Issue relation commands: list, add, and remove. + Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/relation.rb. """ - alias LinearCli.CLI.{Display, Projects, Prompt, WhatFor} + alias LinearCli.CLI.{Display, Prompt} alias LinearCli.CLI.Issue.Identifiers - alias LinearCli.{Linear, Profiles} - - @max_concurrent_issue_updates 20 - - @doc """ - Moves issues to a target project. - - Two modes: - - **ID-based** (EXT-9): `ISSUE_ID... --project P [--team T]` — moves the - listed issues to the named project, resolved per-issue from the issue's - own team or the given `--team`. Concurrent apply, same pattern as - `issue_status/1`. - - **Bulk project-to-project** (Phase 12): `--from P --to P [--team T]` — - lists all open issues (or all with `--all`) from the source project and - fans out mutations to the target project concurrently. - - With `--dry-run`, prints the planned moves without mutating. - Without `--yes`, asks for confirmation before applying. - """ - @spec issue_move(Optimus.ParseResult.t()) :: :ok | {:error, term()} - def issue_move(%{unknown: issue_ids, options: options, flags: flags}) do - cond do - options.from && options.to -> - move_issues_by_project(options, flags) - - options.from || options.to -> - {:error, - {:smells_bad, "--from and --to must both be given for bulk project-to-project mode"}} - - true -> - move_issues_by_id(issue_ids, options, flags) - end - end - - defp move_issues_by_id(issue_ids, options, flags) do - with :ok <- validate_issue_ids(issue_ids), - {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), - {:ok, project} <- resolve_move_project(issues, options) do - print_move_plan(issues, project, options.output) - execute_moves_if_confirmed(issues, project, flags, options.output) - end - end - - defp resolve_move_project(issues, options) do - with {:ok, tid} <- resolve_move_team_id(options.team || Profiles.default_team(), issues), - {:ok, projects} <- Linear.projects_by_team(tid, %{search: options.project}) do - project_result(Projects.project_for(projects, options.project), options.project) - end - end - - defp project_result(nil, search), - do: {:error, {:smells_bad, "No project found matching #{inspect(search)}"}} - - defp project_result(project, _search), do: {:ok, project} - - defp resolve_move_team_id(nil, issues), do: {:ok, hd(issues).team.id} - - defp resolve_move_team_id(key, _issues) do - with {:ok, team} <- Linear.find_team(key), do: {:ok, team.id} - end - - defp execute_moves_if_confirmed(_issues, _project, %{dry_run: true}, _output), do: :ok - - defp execute_moves_if_confirmed(issues, project, %{yes: true}, output), - do: apply_moves(issues, project, output) - - defp execute_moves_if_confirmed(issues, project, _flags, output) do - if Prompt.yes?("Proceed with move?"), - do: apply_moves(issues, project, output), - else: Prompt.warn("Move cancelled") - end - - defp print_move_plan(issues, project, output) when output != "json" do - Enum.each(issues, fn issue -> - Prompt.say("#{issue.identifier} -> #{project.name}") - end) - end - - defp print_move_plan(_issues, _project, _output), do: :ok - - defp apply_moves(issues, project, output) do - issues - |> Task.async_stream( - fn issue -> apply_move(issue, project) end, - max_concurrency: min(length(issues), @max_concurrent_issue_updates), - ordered: true, - timeout: 30_000 - ) - |> Enum.reduce_while({:ok, []}, fn - {:ok, {:ok, updated}}, {:ok, acc} -> {:cont, {:ok, [updated | acc]}} - {:ok, {:error, reason}}, _acc -> {:halt, {:error, reason}} - {:exit, reason}, _acc -> {:halt, {:error, {:task_exit, reason}}} - end) - |> display_moves_result(project, output) - end - - defp display_moves_result({:ok, updated_issues}, project, output) do - updated_issues = Enum.reverse(updated_issues) - Display.show(one_or_many(updated_issues), %{output: output}) - print_move_results(updated_issues, project, output) - :ok - end - - defp display_moves_result(error, _project, _output), do: error - - defp print_move_results(updated_issues, project, output) when output != "json" do - Enum.each(updated_issues, fn updated -> - Prompt.ok("#{updated.identifier} moved to #{project.name}") - end) - end - - defp print_move_results(_updated_issues, _project, _output), do: :ok - - defp apply_move(issue, project) do - Linear.attach_issue_to_project(issue, project.id) - end - - defp validate_issue_ids([]), do: {:error, {:smells_bad, "No issue IDs provided!"}} - defp validate_issue_ids(_issue_ids), do: :ok - - @uuid_regex ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - - defp move_issues_by_project(options, flags) do - team_fn = fn -> WhatFor.team_for(options.team || Profiles.default_team()) end - - with {:ok, source} <- resolve_bulk_project(options.from, team_fn), - {:ok, target} <- resolve_bulk_project(options.to, team_fn), - :ok <- guard_different_projects(source, target), - {:ok, issues} <- Linear.issues(%{project_id: source.id, mine: false, all: flags.all}) do - cond do - issues == [] -> - label = if flags.all, do: "issues", else: "open issues" - Prompt.ok("No #{label} in #{source.name} to move") - :ok - - flags.dry_run -> - Display.show(one_or_many(issues), %{output: options.output}) - Prompt.ok("Would move #{length(issues)} issue(s) from #{source.name} to #{target.name}") - :ok - - not flags.yes and - not Prompt.yes?( - "Move #{length(issues)} issue(s) from #{source.name} to #{target.name}?" - ) -> - Prompt.warn("Move cancelled") - - true -> - with {:ok, pairs} <- apply_project_moves(issues, target) do - show_move_results(pairs, source, target, options.output) - end - end - end - end - - defp resolve_bulk_project(value, team_fn) do - if Regex.match?(@uuid_regex, value) do - short_name = String.slice(value, 0, 8) <> "…" - {:ok, struct(LinearCli.Linear.Project, %{id: value, name: short_name})} - else - team = team_fn.() - - with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: value}), - project when not is_nil(project) <- Projects.project_for(projects, value) do - {:ok, project} - else - nil -> {:error, {:smells_bad, "No project found matching #{value}"}} - {:error, reason} -> {:error, reason} - end - end - end - - defp guard_different_projects(%{id: id}, %{id: id}), - do: {:error, {:smells_bad, "source and target are the same project"}} - - defp guard_different_projects(_source, _target), do: :ok - - defp apply_project_moves(issues, target) do - issues - |> Task.async_stream( - fn issue -> - case Linear.attach_issue_to_project(issue, target.id) do - {:ok, updated} -> {:ok, {issue, updated}} - {:error, reason} -> {:error, reason} - end - end, - max_concurrency: min(length(issues), @max_concurrent_issue_updates), - ordered: true, - timeout: 30_000 - ) - |> Enum.reduce_while({:ok, []}, fn - {:ok, {:ok, pair}}, {:ok, acc} -> {:cont, {:ok, [pair | acc]}} - {:ok, {:error, reason}}, {:ok, _acc} -> {:halt, {:error, reason}} - {:exit, reason}, {:ok, _acc} -> {:halt, {:error, {:task_exit, reason}}} - end) - |> then(fn - {:ok, results} -> {:ok, Enum.reverse(results)} - error -> error - end) - end - - defp show_move_results(pairs, source, target, output) do - if output == "json" do - Display.show(one_or_many(Enum.map(pairs, &elem(&1, 1))), %{output: "json"}) - else - Enum.each(pairs, fn {orig, _updated} -> - Prompt.ok("#{orig.identifier} moved to #{target.name}") - end) - - Prompt.ok("Moved #{length(pairs)} issue(s) from #{source.name} to #{target.name}") - end - - :ok - end - - defp one_or_many([one]), do: one - defp one_or_many(many), do: many + alias LinearCli.Linear @doc """ Lists the relationships for a single issue — both outbound (issues this one @@ -399,11 +175,6 @@ defmodule LinearCli.CLI.Commands do defp relation_add_error_message(_reason), do: "unexpected error" - defp truncate_message(msg, max) when byte_size(msg) > max, - do: String.slice(msg, 0, max) <> "…" - - defp truncate_message(msg, _max), do: msg - @doc """ Removes a relationship from `ISSUE` to one or more `RELATED_ISSUE`s. @@ -598,4 +369,9 @@ defmodule LinearCli.CLI.Commands do do: "LINEAR_API_KEY is not set" defp relation_remove_error_message(_reason), do: "unexpected error" + + defp truncate_message(msg, max) when byte_size(msg) > max, + do: String.slice(msg, 0, max) <> "…" + + defp truncate_message(msg, _max), do: msg end diff --git a/app/test/linear_cli/cli/commands/issues/move_test.exs b/app/test/linear_cli/cli/commands/issues/move_test.exs new file mode 100644 index 0000000..d2c3396 --- /dev/null +++ b/app/test/linear_cli/cli/commands/issues/move_test.exs @@ -0,0 +1,749 @@ +defmodule LinearCli.CLI.Commands.Issues.MoveTest do + use ExUnit.Case, async: true + import ExUnit.CaptureIO + import LinearCli.CLI.IssueCommandsHelpers + + describe "issue move" do + defp move_project_map(id \\ "p1", name \\ "Manhattan") do + %{ + "id" => id, + "name" => name, + "content" => nil, + "slugId" => "abc", + "description" => nil, + "url" => "https://linear.app/x/project/#{id}" + } + end + + defp move_team_projects(projects \\ nil) do + nodes = projects || [move_project_map()] + %{"data" => %{"team" => %{"projects" => %{"nodes" => nodes}}}} + end + + defp issue_moved(project_map \\ nil) do + project = project_map || move_project_map() + %{"data" => %{"issueUpdate" => %{"issue" => issue_map(%{"project" => project})}}} + end + + test "--project moves a single issue with --yes (no prompt)" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:project_id, decoded["variables"]["input"]["projectId"]}) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--yes", + "CRY-1" + ]) + end) + + assert_received {:project_id, "p1"} + assert output =~ "CRY-1 -> Manhattan" + assert output =~ "CRY-1 moved to Manhattan" + end + + test "--dry-run prints the plan but does not call issueUpdate" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :mutation_called) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--dry-run", + "CRY-1" + ]) + end) + + refute_received :mutation_called + assert output =~ "CRY-1 -> Manhattan" + refute output =~ "moved to" + end + + test "user declines confirmation, no mutation called" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :mutation_called) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "CRY-1" + ]) + end) + + refute_received :mutation_called + assert output =~ "CRY-1 -> Manhattan" + assert output =~ "Move cancelled" + end + + test "user confirms, mutation is called" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :mutation_called) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io([input: "y\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "CRY-1" + ]) + end) + + assert_received :mutation_called + end + + test "moves multiple issues concurrently with --yes" 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, "issue(id: $id)") -> + identifier = decoded["variables"]["id"] + + issue = + issue_map(%{ + "identifier" => identifier, + "id" => "i-#{identifier}" + }) + + Req.Test.json(conn, %{"data" => %{"issue" => issue}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + identifier = decoded["variables"]["id"] + send(test_pid, {:moved, identifier}) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--yes", + "CRY-1", + "CRY-2" + ]) + end) + + assert_received {:moved, "CRY-1"} + assert_received {:moved, "CRY-2"} + assert output =~ "CRY-1 -> Manhattan" + assert output =~ "CRY-2 -> Manhattan" + end + + test "--output json emits issue JSON without confirmation messages" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--yes", + "--output", + "json", + "CRY-1" + ]) + end) + + assert {:ok, decoded} = Jason.decode(String.trim(output)) + assert decoded["identifier"] == "CRY-1" + refute output =~ "moved to" + refute output =~ "->" + end + + test "with no issue ids, exits 22 (smells bad)" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "move", "--project", "Manhattan"], halt) + end) + + assert_received {:halted, 22} + assert output =~ "No issue IDs provided!" + end + + test "alias 'm' routes to issue move" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :moved) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "m", "--project", "Manhattan", "--yes", "CRY-1"]) + end) + + assert_received :moved + end + + test "alias 'mv' routes to issue move" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "projects(first: 100") -> + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :moved) + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main(["issue", "mv", "--project", "Manhattan", "--yes", "CRY-1"]) + end) + + assert_received :moved + end + + test "--team scopes project search to the given team" 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, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + # Team.Read.Find uses "query($id: String!) { team(id: $id) { ... projects ... } }" + String.contains?(query, "team(id: $id)") && String.contains?(query, "projects") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100") -> + send(test_pid, {:team_id, decoded["variables"]["teamId"]}) + Req.Test.json(conn, move_team_projects()) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, issue_moved()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--project", + "Manhattan", + "--team", + "ENG", + "--yes", + "CRY-1" + ]) + end) + + assert_received {:team_id, "t1"} + end + + # ── Bulk project-to-project mode (--from / --to) ────────────────────── + + defp bulk_issues do + [ + issue_map(%{"id" => "i1", "identifier" => "CRY-1"}), + issue_map(%{"id" => "i2", "identifier" => "CRY-2"}), + issue_map(%{"id" => "i3", "identifier" => "CRY-3"}) + ] + end + + defp bulk_stub_pairs do + [ + {"$teamId", + team_projects([ + project_map("p-src", "Source Project"), + project_map("p-tgt", "Target Project") + ])}, + {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, + {"issues(filter:", issues_response(bulk_issues())}, + {"issueUpdate", issue_updated()} + ] + end + + test "--from/--to moves all open issues from source to target (happy path)" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + if String.contains?(query, "issueUpdate") do + send(test_pid, {:update, decoded["variables"]}) + end + + case Enum.find(bulk_stub_pairs(), fn {match, _} -> String.contains?(query, match) end) do + {_match, response} -> Req.Test.json(conn, response) + nil -> raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--from", + "Source Project", + "--to", + "Target Project", + "--team", + "ENG", + "--yes" + ]) + end) + + assert output =~ "Target Project" + + assert_received {:update, vars1} + assert vars1["input"]["projectId"] == "p-tgt" + assert_received {:update, vars2} + assert vars2["input"]["projectId"] == "p-tgt" + assert_received {:update, vars3} + assert vars3["input"]["projectId"] == "p-tgt" + end + + test "--from/--to --all sends list query without completedAt/canceledAt guards" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + %{"query" => query} = decoded + + if String.contains?(query, "issues(filter:") do + filter = decoded["variables"]["filter"] + send(test_pid, {:filter, filter}) + end + + case Enum.find(bulk_stub_pairs(), fn {match, _} -> String.contains?(query, match) end) do + {_match, response} -> Req.Test.json(conn, response) + nil -> raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--from", + "Source Project", + "--to", + "Target Project", + "--team", + "ENG", + "--yes", + "--all" + ]) + end) + + assert_received {:filter, filter} + refute Map.has_key?(filter, "completedAt") + refute Map.has_key?(filter, "canceledAt") + end + + test "--from/--to --dry-run resolves issues but sends no issueUpdate" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + if String.contains?(query, "issueUpdate") do + raise "--dry-run must not send any issueUpdate" + end + + case Enum.find(bulk_stub_pairs(), fn {match, _} -> String.contains?(query, match) end) do + {_match, response} -> Req.Test.json(conn, response) + nil -> raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--from", + "Source Project", + "--to", + "Target Project", + "--team", + "ENG", + "--dry-run" + ]) + end) + + assert output =~ "Would move" + end + + test "--from/--to error mid-batch halts with non-zero exit" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + call_count = :counters.new(1, []) + + 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, "$teamId") -> + Req.Test.json( + conn, + team_projects([ + project_map("p-src", "Source Project"), + project_map("p-tgt", "Target Project") + ]) + ) + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "issues(filter:") -> + Req.Test.json(conn, issues_response(bulk_issues())) + + String.contains?(query, "issueUpdate") -> + :counters.add(call_count, 1, 1) + n = :counters.get(call_count, 1) + + if n >= 2 do + Req.Test.json(conn, %{"errors" => [%{"message" => "update failed"}]}) + else + Req.Test.json(conn, issue_updated()) + end + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(:stderr, fn -> + LinearCli.CLI.main( + [ + "issue", + "move", + "--from", + "Source Project", + "--to", + "Target Project", + "--team", + "ENG", + "--yes" + ], + halt + ) + end) + + assert_received {:halted, _code} + end + + test "--from/--to --output json emits JSON array of moved issues" do + stub_responses(bulk_stub_pairs()) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--from", + "Source Project", + "--to", + "Target Project", + "--team", + "ENG", + "--yes", + "--output", + "json" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert is_list(decoded) + assert length(decoded) == 3 + end + + test "--from/--to UUID skips project-search queries" do + src_uuid = "00000000-0000-1000-8000-000000000001" + tgt_uuid = "00000000-0000-1000-8000-000000000002" + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + if String.contains?(query, "$teamId") do + raise "UUID --from/--to must not send any project-search query" + end + + cond do + String.contains?(query, "issues(filter:") -> + Req.Test.json(conn, issues_response(bulk_issues())) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, issue_updated()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--from", + src_uuid, + "--to", + tgt_uuid, + "--yes" + ]) + end) + + assert output =~ "moved to" + end + + test "--from/--to identical source and target UUIDs error before listing" do + same_uuid = "00000000-0000-1000-8000-000000000001" + 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" => query} = Jason.decode!(body) + raise "no API call should be made for same-ID move; got: #{query}" + end) + + capture_io(:stderr, fn -> + LinearCli.CLI.main( + ["issue", "move", "--from", same_uuid, "--to", same_uuid], + halt + ) + end) + + assert_received {:halted, 22} + end + + test "--from/--to user declines prints 'Move cancelled'" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + case Enum.find(bulk_stub_pairs(), fn {match, _} -> String.contains?(query, match) end) do + {_match, response} -> Req.Test.json(conn, response) + nil -> raise "no stub matched query: #{query}" + end + end) + + output = + capture_io([input: "n\n"], fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "move", + "--from", + "Source Project", + "--to", + "Target Project", + "--team", + "ENG" + ]) + end) + + assert output =~ "Move cancelled" + end + + test "--from without --to exits 22 with a clear error" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "move", "--from", "Source Project"], halt) + end) + + assert_received {:halted, 22} + assert output =~ "--from and --to must both be given" + end + + test "--to without --from exits 22 with a clear error" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + output = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "move", "--to", "Target Project"], halt) + end) + + assert_received {:halted, 22} + assert output =~ "--from and --to must both be given" + end + end +end diff --git a/app/test/linear_cli/cli/commands/issues/relations_test.exs b/app/test/linear_cli/cli/commands/issues/relations_test.exs new file mode 100644 index 0000000..a05cd0f --- /dev/null +++ b/app/test/linear_cli/cli/commands/issues/relations_test.exs @@ -0,0 +1,841 @@ +defmodule LinearCli.CLI.Commands.Issues.RelationsTest do + use ExUnit.Case, async: true + import ExUnit.CaptureIO + + alias LinearCli.CLI.Commands.Issues.Relations + + describe "issue relation list" do + defp relation_node(id, type, src_ident, rel_ident) do + %{ + "id" => id, + "type" => type, + "issue" => %{ + "id" => "i-src", + "identifier" => src_ident, + "title" => "#{src_ident} title", + "url" => "u" + }, + "relatedIssue" => %{ + "id" => "i-rel", + "identifier" => rel_ident, + "title" => "#{rel_ident} title", + "url" => "u" + } + } + end + + defp relation_edge(node), do: %{"node" => node, "cursor" => "c-#{node["id"]}"} + + defp relations_stub(out_edges, inv_edges) do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + is_inverse = String.contains?(body, "inverseRelations") + + data = + if is_inverse do + %{ + "data" => %{ + "issue" => %{ + "inverseRelations" => %{ + "edges" => inv_edges, + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + } + else + %{ + "data" => %{ + "issue" => %{ + "relations" => %{ + "edges" => out_edges, + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + } + end + + Req.Test.json(conn, data) + end) + end + + test "displays Blocks section for outbound blocks relations" do + relations_stub( + [relation_edge(relation_node("r1", "blocks", "EXT-1", "EXT-2"))], + [] + ) + + output = + capture_io(fn -> + Relations.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "text"}}) + end) + + assert output =~ "Blocks:" + assert output =~ "EXT-2" + end + + test "displays Blocked by section for inbound blocks relations" do + relations_stub( + [], + [relation_edge(relation_node("r1", "blocks", "EXT-3", "EXT-1"))] + ) + + output = + capture_io(fn -> + Relations.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "text"}}) + end) + + assert output =~ "Blocked by:" + assert output =~ "EXT-3" + end + + test "returns empty output when issue has no relations" do + relations_stub([], []) + + output = + capture_io(fn -> + Relations.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "text"}}) + end) + + assert String.trim(output) == "" + end + + test "JSON output includes all relation fields" do + relations_stub( + [relation_edge(relation_node("r1", "blocks", "EXT-1", "EXT-2"))], + [] + ) + + output = + capture_io(fn -> + Relations.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "json"}}) + end) + + [entry] = Jason.decode!(output) + assert entry["id"] == "r1" + assert entry["type"] == "blocks" + assert entry["direction"] == "outbound" + end + end + + describe "issue relation add" do + defp create_success_response(id, type, src_ident, rel_ident) do + %{ + "data" => %{ + "issueRelationCreate" => %{ + "success" => true, + "issueRelation" => %{ + "id" => id, + "type" => type, + "issue" => %{ + "id" => "i-src", + "identifier" => src_ident, + "title" => "#{src_ident} title", + "url" => "https://example.com/#{src_ident}" + }, + "relatedIssue" => %{ + "id" => "i-rel", + "identifier" => rel_ident, + "title" => "#{rel_ident} title", + "url" => "https://example.com/#{rel_ident}" + } + } + } + } + } + end + + defp create_duplicate_response do + %{ + "errors" => [ + %{"message" => "A relation of this type already exists between these issues"} + ] + } + end + + defp create_error_response(message) do + %{"errors" => [%{"message" => message}]} + end + + defp add_parse_result(subject, related_ids, type) do + %{ + unknown: [subject | related_ids], + options: %{output: "text", type: type} + } + end + + test "creates a blocks relation and prints the result" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-1", "EXT-2")) + end) + + output = + capture_io(fn -> + Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) + end) + + assert output =~ "EXT-1 now blocks EXT-2" + end + + test "blocked-by sends reversed endpoints to Linear" do + parent = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => vars} = Jason.decode!(body) + send(parent, {:vars, vars}) + Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-3", "EXT-1")) + end) + + capture_io(fn -> + Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-3"], "blocked-by")) + end) + + assert_received {:vars, + %{"issueId" => "EXT-3", "relatedIssueId" => "EXT-1", "type" => "blocks"}} + end + + test "blocked-by prints direction from subject's perspective" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-3", "EXT-1")) + end) + + output = + capture_io(fn -> + Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-3"], "blocked-by")) + end) + + assert output =~ "EXT-3 now blocks EXT-1" + end + + test "processes multiple related issues" do + call_count = :counters.new(1, []) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + :counters.add(call_count, 1, 1) + %{"variables" => %{"relatedIssueId" => related}} = Jason.decode!(body) + Req.Test.json(conn, create_success_response("r#{related}", "blocks", "EXT-1", related)) + end) + + output = + capture_io(fn -> + Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-2", "EXT-3"], "blocks")) + end) + + assert :counters.get(call_count, 1) == 2 + assert output =~ "EXT-1 now blocks EXT-2" + assert output =~ "EXT-1 now blocks EXT-3" + end + + test "treats duplicate relation as informative no-op" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_duplicate_response()) + end) + + output = + capture_io(fn -> + result = Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) + assert result == :ok + end) + + assert output =~ "already exists" + end + + test "rejects self-link without calling the API" do + Req.Test.stub(LinearCli.Api, fn _conn -> + raise "should not be called" + end) + + output_stderr = + capture_io(:stderr, fn -> + result = Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-1"], "blocks")) + assert {:error, {:smells_bad, _}} = result + end) + + assert output_stderr =~ "self-link" + end + + test "returns error when no related issues provided" do + assert {:error, {:smells_bad, _}} = + Relations.issue_relation_add(%{ + unknown: ["EXT-1"], + options: %{output: "text", type: "blocks"} + }) + end + + test "returns error when no issue ids provided" do + assert {:error, {:smells_bad, _}} = + Relations.issue_relation_add(%{ + unknown: [], + options: %{output: "text", type: "blocks"} + }) + end + + test "exits non-zero on full failure and prints to stderr" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_error_response("Unauthorized")) + end) + + stderr = + capture_io(:stderr, fn -> + result = Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) + assert {:error, {:smells_bad, msg}} = result + assert msg =~ "failed" + end) + + assert stderr =~ "EXT-2: Linear API error: Unauthorized" + end + + test "partial failure: succeeds for valid targets, errors for failed targets" do + call_count = :counters.new(1, []) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + :counters.add(call_count, 1, 1) + %{"variables" => %{"relatedIssueId" => related}} = Jason.decode!(body) + + if related == "EXT-2" do + Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-1", "EXT-2")) + else + Req.Test.json(conn, create_error_response("Unauthorized")) + end + end) + + stderr = + capture_io(:stderr, fn -> + output = + capture_io(fn -> + result = + Relations.issue_relation_add( + add_parse_result("EXT-1", ["EXT-2", "EXT-bad"], "blocks") + ) + + assert {:error, {:smells_bad, _}} = result + end) + + send(self(), {:relation_add_output, output}) + end) + + assert_received {:relation_add_output, output} + assert output =~ "EXT-1 now blocks EXT-2" + assert stderr =~ "EXT-bad: Linear API error: Unauthorized" + assert :counters.get(call_count, 1) == 2 + end + + test "JSON output contains per-target status and relation for success" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-1", "EXT-2")) + end) + + output = + capture_io(fn -> + Relations.issue_relation_add(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "created" + assert entry["target"] == "EXT-2" + assert entry["relation"]["type"] == "blocks" + end + + test "JSON output shows exists status for duplicate" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_duplicate_response()) + end) + + output = + capture_io(fn -> + Relations.issue_relation_add(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "exists" + assert entry["target"] == "EXT-2" + end + + test "JSON output shows error status for self-link" do + Req.Test.stub(LinearCli.Api, fn _conn -> raise "should not be called" end) + + output = + capture_io(:stderr, fn -> + output_stdout = + capture_io(fn -> + Relations.issue_relation_add(%{ + unknown: ["EXT-1", "EXT-1"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output_stdout) + assert entry["status"] == "error" + assert entry["target"] == "EXT-1" + assert entry["message"] =~ "self-link" + end) + + assert output == "" + end + + test "JSON output shows actual error message for API failures" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_error_response("Unauthorized")) + end) + + {_result, output} = + with_io(fn -> + Relations.issue_relation_add(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "error" + assert entry["target"] == "EXT-2" + assert entry["message"] =~ "Unauthorized" + end + + test "creates a related relation and prints grammatically correct text" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_success_response("r1", "related", "EXT-1", "EXT-2")) + end) + + output = + capture_io(fn -> + Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "related")) + end) + + assert output =~ "EXT-1 is now related to EXT-2" + end + + test "creates a duplicate relation and prints grammatically correct text" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, create_success_response("r1", "duplicate", "EXT-1", "EXT-2")) + end) + + output = + capture_io(fn -> + Relations.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "duplicate")) + end) + + assert output =~ "EXT-1 is now a duplicate of EXT-2" + end + end + + describe "issue relation remove" do + defp remove_parse_result(subject, related_ids, type) do + %{ + unknown: [subject | related_ids], + options: %{output: "text", type: type} + } + end + + defp remove_relations_stub(out_nodes, inv_nodes) do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + + cond do + String.contains?(body, "issueRelationDelete") -> + %{"variables" => %{"id" => id}} = decoded + + Req.Test.json(conn, %{ + "data" => %{ + "issueRelationDelete" => %{"success" => true, "entityId" => id} + } + }) + + String.contains?(body, "inverseRelations") -> + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "inverseRelations" => %{ + "edges" => Enum.map(inv_nodes, &%{"node" => &1, "cursor" => "c"}), + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + }) + + true -> + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "relations" => %{ + "edges" => Enum.map(out_nodes, &%{"node" => &1, "cursor" => "c"}), + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + }) + end + end) + end + + defp remove_relation_node(id, type, src_ident, rel_ident) do + %{ + "id" => id, + "type" => type, + "issue" => %{ + "id" => "i-src", + "identifier" => src_ident, + "title" => "#{src_ident} title", + "url" => "https://example.com/#{src_ident}" + }, + "relatedIssue" => %{ + "id" => "i-rel", + "identifier" => rel_ident, + "title" => "#{rel_ident} title", + "url" => "https://example.com/#{rel_ident}" + } + } + end + + test "removes a blocks relation and prints the result" do + remove_relations_stub( + [remove_relation_node("r1", "blocks", "EXT-1", "EXT-2")], + [] + ) + + output = + capture_io(fn -> + assert :ok = + Relations.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2"], "blocks") + ) + end) + + assert output =~ "EXT-1 no longer blocks EXT-2" + end + + test "removes a related relation and prints correct text" do + remove_relations_stub( + [remove_relation_node("r1", "related", "EXT-1", "EXT-2")], + [] + ) + + output = + capture_io(fn -> + assert :ok = + Relations.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2"], "related") + ) + end) + + assert output =~ "EXT-1 is no longer related to EXT-2" + end + + test "removes a duplicate relation and prints correct text" do + remove_relations_stub( + [remove_relation_node("r1", "duplicate", "EXT-1", "EXT-2")], + [] + ) + + output = + capture_io(fn -> + assert :ok = + Relations.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2"], "duplicate") + ) + end) + + assert output =~ "EXT-1 is no longer a duplicate of EXT-2" + end + + test "absent relation is a no-op and returns :ok" do + remove_relations_stub([], []) + + output = + capture_io(fn -> + assert :ok = + Relations.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2"], "blocks") + ) + end) + + assert output =~ "not found" + end + + test "blocked-by matches the inbound blocks relation" do + remove_relations_stub( + [], + [remove_relation_node("r1", "blocks", "EXT-3", "EXT-1")] + ) + + output = + capture_io(fn -> + assert :ok = + Relations.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-3"], "blocked-by") + ) + end) + + assert output =~ "EXT-3 no longer blocks EXT-1" + end + + test "blocked-by with no matching inbound relation is a no-op" do + remove_relations_stub([], []) + + output = + capture_io(fn -> + assert :ok = + Relations.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-3"], "blocked-by") + ) + end) + + assert output =~ "not found" + end + + test "processes multiple related issues independently" do + remove_relations_stub( + [ + remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + remove_relation_node("r2", "blocks", "EXT-1", "EXT-3") + ], + [] + ) + + output = + capture_io(fn -> + assert :ok = + Relations.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2", "EXT-3"], "blocks") + ) + end) + + assert output =~ "EXT-1 no longer blocks EXT-2" + assert output =~ "EXT-1 no longer blocks EXT-3" + end + + test "rejects self-link without calling the delete mutation" do + remove_relations_stub([], []) + + output_stderr = + capture_io(:stderr, fn -> + result = + Relations.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-1"], "blocks")) + + assert {:error, {:smells_bad, _}} = result + end) + + assert output_stderr =~ "self-link" + end + + test "returns error when no related issues provided" do + assert {:error, {:smells_bad, _}} = + Relations.issue_relation_remove(%{ + unknown: ["EXT-1"], + options: %{output: "text", type: "blocks"} + }) + end + + test "returns error when no issue ids provided" do + assert {:error, {:smells_bad, _}} = + Relations.issue_relation_remove(%{ + unknown: [], + options: %{output: "text", type: "blocks"} + }) + end + + test "ambiguous match fails that target and lists all matching ids" do + remove_relations_stub( + [ + remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") + ], + [] + ) + + output_stderr = + capture_io(:stderr, fn -> + result = + capture_io(fn -> + Relations.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-2"], "blocks")) + end) + + _ = result + end) + + assert output_stderr =~ "ambiguous" + assert output_stderr =~ "r1" + assert output_stderr =~ "r2" + end + + test "ambiguous match exits non-zero" do + remove_relations_stub( + [ + remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") + ], + [] + ) + + {result, _output} = + with_io(fn -> + Relations.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-2"], "blocks")) + end) + + assert {:error, {:smells_bad, msg}} = result + assert msg =~ "failed" + end + + test "partial failure: succeeds for absent target, errors for ambiguous" do + remove_relations_stub( + [ + remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") + ], + [] + ) + + {result, _output} = + with_io(fn -> + Relations.issue_relation_remove( + remove_parse_result("EXT-1", ["EXT-2", "EXT-3"], "blocks") + ) + end) + + assert {:error, {:smells_bad, _}} = result + end + + test "API error on delete causes that target to fail" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + if String.contains?(body, "issueRelationDelete") do + Req.Test.json(conn, %{"errors" => [%{"message" => "Unauthorized"}]}) + else + data = + if String.contains?(body, "inverseRelations") do + %{ + "data" => %{ + "issue" => %{ + "inverseRelations" => %{ + "edges" => [], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + } + else + %{ + "data" => %{ + "issue" => %{ + "relations" => %{ + "edges" => [ + %{ + "node" => remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + "cursor" => "c" + } + ], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + } + end + + Req.Test.json(conn, data) + end + end) + + {result, _output} = + with_io(fn -> + Relations.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-2"], "blocks")) + end) + + assert {:error, {:smells_bad, _}} = result + end + + test "JSON output shows removed status with relation for success" do + remove_relations_stub( + [remove_relation_node("r1", "blocks", "EXT-1", "EXT-2")], + [] + ) + + output = + capture_io(fn -> + Relations.issue_relation_remove(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "removed" + assert entry["target"] == "EXT-2" + assert entry["relation"]["type"] == "blocks" + end + + test "JSON output shows absent status for missing relation" do + remove_relations_stub([], []) + + output = + capture_io(fn -> + Relations.issue_relation_remove(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "absent" + assert entry["target"] == "EXT-2" + end + + test "JSON output shows error with all ids for ambiguous match" do + remove_relations_stub( + [ + remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), + remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") + ], + [] + ) + + output = + capture_io(fn -> + Relations.issue_relation_remove(%{ + unknown: ["EXT-1", "EXT-2"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output) + assert entry["status"] == "error" + assert entry["target"] == "EXT-2" + assert entry["message"] =~ "ambiguous" + assert entry["message"] =~ "r1" + assert entry["message"] =~ "r2" + end + + test "JSON output shows error for self-link" do + remove_relations_stub([], []) + + output = + capture_io(:stderr, fn -> + output_stdout = + capture_io(fn -> + Relations.issue_relation_remove(%{ + unknown: ["EXT-1", "EXT-1"], + options: %{output: "json", type: "blocks"} + }) + end) + + [entry] = Jason.decode!(output_stdout) + assert entry["status"] == "error" + assert entry["message"] =~ "self-link" + end) + + assert output == "" + end + end +end diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs deleted file mode 100644 index be3a649..0000000 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ /dev/null @@ -1,1593 +0,0 @@ -defmodule LinearCli.CLI.IssueCommandsTest do - use ExUnit.Case, async: true - import ExUnit.CaptureIO - import LinearCli.CLI.IssueCommandsHelpers - - alias LinearCli.CLI.Commands - - describe "issue move" do - defp move_project_map(id \\ "p1", name \\ "Manhattan") do - %{ - "id" => id, - "name" => name, - "content" => nil, - "slugId" => "abc", - "description" => nil, - "url" => "https://linear.app/x/project/#{id}" - } - end - - defp move_team_projects(projects \\ nil) do - nodes = projects || [move_project_map()] - %{"data" => %{"team" => %{"projects" => %{"nodes" => nodes}}}} - end - - defp issue_moved(project_map \\ nil) do - project = project_map || move_project_map() - %{"data" => %{"issueUpdate" => %{"issue" => issue_map(%{"project" => project})}}} - end - - test "--project moves a single issue with --yes (no prompt)" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "projects(first: 100") -> - Req.Test.json(conn, move_team_projects()) - - String.contains?(query, "issueUpdate") -> - send(test_pid, {:project_id, decoded["variables"]["input"]["projectId"]}) - Req.Test.json(conn, issue_moved()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--project", - "Manhattan", - "--yes", - "CRY-1" - ]) - end) - - assert_received {:project_id, "p1"} - assert output =~ "CRY-1 -> Manhattan" - assert output =~ "CRY-1 moved to Manhattan" - end - - test "--dry-run prints the plan but does not call issueUpdate" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "projects(first: 100") -> - Req.Test.json(conn, move_team_projects()) - - String.contains?(query, "issueUpdate") -> - send(test_pid, :mutation_called) - Req.Test.json(conn, issue_moved()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--project", - "Manhattan", - "--dry-run", - "CRY-1" - ]) - end) - - refute_received :mutation_called - assert output =~ "CRY-1 -> Manhattan" - refute output =~ "moved to" - end - - test "user declines confirmation, no mutation called" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "projects(first: 100") -> - Req.Test.json(conn, move_team_projects()) - - String.contains?(query, "issueUpdate") -> - send(test_pid, :mutation_called) - Req.Test.json(conn, issue_moved()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io([input: "n\n"], fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--project", - "Manhattan", - "CRY-1" - ]) - end) - - refute_received :mutation_called - assert output =~ "CRY-1 -> Manhattan" - assert output =~ "Move cancelled" - end - - test "user confirms, mutation is called" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "projects(first: 100") -> - Req.Test.json(conn, move_team_projects()) - - String.contains?(query, "issueUpdate") -> - send(test_pid, :mutation_called) - Req.Test.json(conn, issue_moved()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io([input: "y\n"], fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--project", - "Manhattan", - "CRY-1" - ]) - end) - - assert_received :mutation_called - end - - test "moves multiple issues concurrently with --yes" 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, "issue(id: $id)") -> - identifier = decoded["variables"]["id"] - - issue = - issue_map(%{ - "identifier" => identifier, - "id" => "i-#{identifier}" - }) - - Req.Test.json(conn, %{"data" => %{"issue" => issue}}) - - String.contains?(query, "projects(first: 100") -> - Req.Test.json(conn, move_team_projects()) - - String.contains?(query, "issueUpdate") -> - identifier = decoded["variables"]["id"] - send(test_pid, {:moved, identifier}) - Req.Test.json(conn, issue_moved()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--project", - "Manhattan", - "--yes", - "CRY-1", - "CRY-2" - ]) - end) - - assert_received {:moved, "CRY-1"} - assert_received {:moved, "CRY-2"} - assert output =~ "CRY-1 -> Manhattan" - assert output =~ "CRY-2 -> Manhattan" - end - - test "--output json emits issue JSON without confirmation messages" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "projects(first: 100") -> - Req.Test.json(conn, move_team_projects()) - - String.contains?(query, "issueUpdate") -> - Req.Test.json(conn, issue_moved()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--project", - "Manhattan", - "--yes", - "--output", - "json", - "CRY-1" - ]) - end) - - assert {:ok, decoded} = Jason.decode(String.trim(output)) - assert decoded["identifier"] == "CRY-1" - refute output =~ "moved to" - refute output =~ "->" - end - - test "with no issue ids, exits 22 (smells bad)" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "move", "--project", "Manhattan"], halt) - end) - - assert_received {:halted, 22} - assert output =~ "No issue IDs provided!" - end - - test "alias 'm' routes to issue move" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "projects(first: 100") -> - Req.Test.json(conn, move_team_projects()) - - String.contains?(query, "issueUpdate") -> - send(test_pid, :moved) - Req.Test.json(conn, issue_moved()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = - LinearCli.CLI.main(["issue", "m", "--project", "Manhattan", "--yes", "CRY-1"]) - end) - - assert_received :moved - end - - test "alias 'mv' routes to issue move" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - String.contains?(query, "projects(first: 100") -> - Req.Test.json(conn, move_team_projects()) - - String.contains?(query, "issueUpdate") -> - send(test_pid, :moved) - Req.Test.json(conn, issue_moved()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = - LinearCli.CLI.main(["issue", "mv", "--project", "Manhattan", "--yes", "CRY-1"]) - end) - - assert_received :moved - end - - test "--team scopes project search to the given team" 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, "issue(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) - - # Team.Read.Find uses "query($id: String!) { team(id: $id) { ... projects ... } }" - String.contains?(query, "team(id: $id)") && String.contains?(query, "projects") -> - Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) - - String.contains?(query, "projects(first: 100") -> - send(test_pid, {:team_id, decoded["variables"]["teamId"]}) - Req.Test.json(conn, move_team_projects()) - - String.contains?(query, "issueUpdate") -> - Req.Test.json(conn, issue_moved()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--project", - "Manhattan", - "--team", - "ENG", - "--yes", - "CRY-1" - ]) - end) - - assert_received {:team_id, "t1"} - end - - # ── Bulk project-to-project mode (--from / --to) ────────────────────── - - defp bulk_issues do - [ - issue_map(%{"id" => "i1", "identifier" => "CRY-1"}), - issue_map(%{"id" => "i2", "identifier" => "CRY-2"}), - issue_map(%{"id" => "i3", "identifier" => "CRY-3"}) - ] - end - - defp bulk_stub_pairs do - [ - {"$teamId", - team_projects([ - project_map("p-src", "Source Project"), - project_map("p-tgt", "Target Project") - ])}, - {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, - {"issues(filter:", issues_response(bulk_issues())}, - {"issueUpdate", issue_updated()} - ] - end - - test "--from/--to moves all open issues from source to target (happy path)" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - if String.contains?(query, "issueUpdate") do - send(test_pid, {:update, decoded["variables"]}) - end - - case Enum.find(bulk_stub_pairs(), fn {match, _} -> String.contains?(query, match) end) do - {_match, response} -> Req.Test.json(conn, response) - nil -> raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--from", - "Source Project", - "--to", - "Target Project", - "--team", - "ENG", - "--yes" - ]) - end) - - assert output =~ "Target Project" - - assert_received {:update, vars1} - assert vars1["input"]["projectId"] == "p-tgt" - assert_received {:update, vars2} - assert vars2["input"]["projectId"] == "p-tgt" - assert_received {:update, vars3} - assert vars3["input"]["projectId"] == "p-tgt" - end - - test "--from/--to --all sends list query without completedAt/canceledAt guards" do - test_pid = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - %{"query" => query} = decoded - - if String.contains?(query, "issues(filter:") do - filter = decoded["variables"]["filter"] - send(test_pid, {:filter, filter}) - end - - case Enum.find(bulk_stub_pairs(), fn {match, _} -> String.contains?(query, match) end) do - {_match, response} -> Req.Test.json(conn, response) - nil -> raise "no stub matched query: #{query}" - end - end) - - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--from", - "Source Project", - "--to", - "Target Project", - "--team", - "ENG", - "--yes", - "--all" - ]) - end) - - assert_received {:filter, filter} - refute Map.has_key?(filter, "completedAt") - refute Map.has_key?(filter, "canceledAt") - end - - test "--from/--to --dry-run resolves issues but sends no issueUpdate" do - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => query} = Jason.decode!(body) - - if String.contains?(query, "issueUpdate") do - raise "--dry-run must not send any issueUpdate" - end - - case Enum.find(bulk_stub_pairs(), fn {match, _} -> String.contains?(query, match) end) do - {_match, response} -> Req.Test.json(conn, response) - nil -> raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--from", - "Source Project", - "--to", - "Target Project", - "--team", - "ENG", - "--dry-run" - ]) - end) - - assert output =~ "Would move" - end - - test "--from/--to error mid-batch halts with non-zero exit" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - call_count = :counters.new(1, []) - - 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, "$teamId") -> - Req.Test.json( - conn, - team_projects([ - project_map("p-src", "Source Project"), - project_map("p-tgt", "Target Project") - ]) - ) - - String.contains?(query, "team(id: $id)") -> - Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) - - String.contains?(query, "issues(filter:") -> - Req.Test.json(conn, issues_response(bulk_issues())) - - String.contains?(query, "issueUpdate") -> - :counters.add(call_count, 1, 1) - n = :counters.get(call_count, 1) - - if n >= 2 do - Req.Test.json(conn, %{"errors" => [%{"message" => "update failed"}]}) - else - Req.Test.json(conn, issue_updated()) - end - - true -> - raise "no stub matched query: #{query}" - end - end) - - capture_io(:stderr, fn -> - LinearCli.CLI.main( - [ - "issue", - "move", - "--from", - "Source Project", - "--to", - "Target Project", - "--team", - "ENG", - "--yes" - ], - halt - ) - end) - - assert_received {:halted, _code} - end - - test "--from/--to --output json emits JSON array of moved issues" do - stub_responses(bulk_stub_pairs()) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--from", - "Source Project", - "--to", - "Target Project", - "--team", - "ENG", - "--yes", - "--output", - "json" - ]) - end) - - assert {:ok, decoded} = Jason.decode(output) - assert is_list(decoded) - assert length(decoded) == 3 - end - - test "--from/--to UUID skips project-search queries" do - src_uuid = "00000000-0000-1000-8000-000000000001" - tgt_uuid = "00000000-0000-1000-8000-000000000002" - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => query} = Jason.decode!(body) - - if String.contains?(query, "$teamId") do - raise "UUID --from/--to must not send any project-search query" - end - - cond do - String.contains?(query, "issues(filter:") -> - Req.Test.json(conn, issues_response(bulk_issues())) - - String.contains?(query, "issueUpdate") -> - Req.Test.json(conn, issue_updated()) - - true -> - raise "no stub matched query: #{query}" - end - end) - - output = - capture_io(fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--from", - src_uuid, - "--to", - tgt_uuid, - "--yes" - ]) - end) - - assert output =~ "moved to" - end - - test "--from/--to identical source and target UUIDs error before listing" do - same_uuid = "00000000-0000-1000-8000-000000000001" - 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" => query} = Jason.decode!(body) - raise "no API call should be made for same-ID move; got: #{query}" - end) - - capture_io(:stderr, fn -> - LinearCli.CLI.main( - ["issue", "move", "--from", same_uuid, "--to", same_uuid], - halt - ) - end) - - assert_received {:halted, 22} - end - - test "--from/--to user declines prints 'Move cancelled'" do - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"query" => query} = Jason.decode!(body) - - case Enum.find(bulk_stub_pairs(), fn {match, _} -> String.contains?(query, match) end) do - {_match, response} -> Req.Test.json(conn, response) - nil -> raise "no stub matched query: #{query}" - end - end) - - output = - capture_io([input: "n\n"], fn -> - assert :ok = - LinearCli.CLI.main([ - "issue", - "move", - "--from", - "Source Project", - "--to", - "Target Project", - "--team", - "ENG" - ]) - end) - - assert output =~ "Move cancelled" - end - - test "--from without --to exits 22 with a clear error" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "move", "--from", "Source Project"], halt) - end) - - assert_received {:halted, 22} - assert output =~ "--from and --to must both be given" - end - - test "--to without --from exits 22 with a clear error" do - test_pid = self() - halt = fn code -> send(test_pid, {:halted, code}) end - - output = - capture_io(:stderr, fn -> - LinearCli.CLI.main(["issue", "move", "--to", "Target Project"], halt) - end) - - assert_received {:halted, 22} - assert output =~ "--from and --to must both be given" - end - end - - describe "issue relation list" do - defp relation_node(id, type, src_ident, rel_ident) do - %{ - "id" => id, - "type" => type, - "issue" => %{ - "id" => "i-src", - "identifier" => src_ident, - "title" => "#{src_ident} title", - "url" => "u" - }, - "relatedIssue" => %{ - "id" => "i-rel", - "identifier" => rel_ident, - "title" => "#{rel_ident} title", - "url" => "u" - } - } - end - - defp relation_edge(node), do: %{"node" => node, "cursor" => "c-#{node["id"]}"} - - defp relations_stub(out_edges, inv_edges) do - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - - is_inverse = String.contains?(body, "inverseRelations") - - data = - if is_inverse do - %{ - "data" => %{ - "issue" => %{ - "inverseRelations" => %{ - "edges" => inv_edges, - "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} - } - } - } - } - else - %{ - "data" => %{ - "issue" => %{ - "relations" => %{ - "edges" => out_edges, - "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} - } - } - } - } - end - - Req.Test.json(conn, data) - end) - end - - test "displays Blocks section for outbound blocks relations" do - relations_stub( - [relation_edge(relation_node("r1", "blocks", "EXT-1", "EXT-2"))], - [] - ) - - output = - capture_io(fn -> - Commands.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "text"}}) - end) - - assert output =~ "Blocks:" - assert output =~ "EXT-2" - end - - test "displays Blocked by section for inbound blocks relations" do - relations_stub( - [], - [relation_edge(relation_node("r1", "blocks", "EXT-3", "EXT-1"))] - ) - - output = - capture_io(fn -> - Commands.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "text"}}) - end) - - assert output =~ "Blocked by:" - assert output =~ "EXT-3" - end - - test "returns empty output when issue has no relations" do - relations_stub([], []) - - output = - capture_io(fn -> - Commands.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "text"}}) - end) - - assert String.trim(output) == "" - end - - test "JSON output includes all relation fields" do - relations_stub( - [relation_edge(relation_node("r1", "blocks", "EXT-1", "EXT-2"))], - [] - ) - - output = - capture_io(fn -> - Commands.issue_relation_list(%{args: %{issue_id: "EXT-1"}, options: %{output: "json"}}) - end) - - [entry] = Jason.decode!(output) - assert entry["id"] == "r1" - assert entry["type"] == "blocks" - assert entry["direction"] == "outbound" - end - end - - describe "issue relation add" do - defp create_success_response(id, type, src_ident, rel_ident) do - %{ - "data" => %{ - "issueRelationCreate" => %{ - "success" => true, - "issueRelation" => %{ - "id" => id, - "type" => type, - "issue" => %{ - "id" => "i-src", - "identifier" => src_ident, - "title" => "#{src_ident} title", - "url" => "https://example.com/#{src_ident}" - }, - "relatedIssue" => %{ - "id" => "i-rel", - "identifier" => rel_ident, - "title" => "#{rel_ident} title", - "url" => "https://example.com/#{rel_ident}" - } - } - } - } - } - end - - defp create_duplicate_response do - %{ - "errors" => [ - %{"message" => "A relation of this type already exists between these issues"} - ] - } - end - - defp create_error_response(message) do - %{"errors" => [%{"message" => message}]} - end - - defp add_parse_result(subject, related_ids, type) do - %{ - unknown: [subject | related_ids], - options: %{output: "text", type: type} - } - end - - test "creates a blocks relation and prints the result" do - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-1", "EXT-2")) - end) - - output = - capture_io(fn -> - Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) - end) - - assert output =~ "EXT-1 now blocks EXT-2" - end - - test "blocked-by sends reversed endpoints to Linear" do - parent = self() - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - %{"variables" => vars} = Jason.decode!(body) - send(parent, {:vars, vars}) - Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-3", "EXT-1")) - end) - - capture_io(fn -> - Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-3"], "blocked-by")) - end) - - assert_received {:vars, - %{"issueId" => "EXT-3", "relatedIssueId" => "EXT-1", "type" => "blocks"}} - end - - test "blocked-by prints direction from subject's perspective" do - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-3", "EXT-1")) - end) - - output = - capture_io(fn -> - Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-3"], "blocked-by")) - end) - - assert output =~ "EXT-3 now blocks EXT-1" - end - - test "processes multiple related issues" do - call_count = :counters.new(1, []) - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - :counters.add(call_count, 1, 1) - %{"variables" => %{"relatedIssueId" => related}} = Jason.decode!(body) - Req.Test.json(conn, create_success_response("r#{related}", "blocks", "EXT-1", related)) - end) - - output = - capture_io(fn -> - Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2", "EXT-3"], "blocks")) - end) - - assert :counters.get(call_count, 1) == 2 - assert output =~ "EXT-1 now blocks EXT-2" - assert output =~ "EXT-1 now blocks EXT-3" - end - - test "treats duplicate relation as informative no-op" do - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, create_duplicate_response()) - end) - - output = - capture_io(fn -> - result = Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) - assert result == :ok - end) - - assert output =~ "already exists" - end - - test "rejects self-link without calling the API" do - Req.Test.stub(LinearCli.Api, fn _conn -> - raise "should not be called" - end) - - output_stderr = - capture_io(:stderr, fn -> - result = Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-1"], "blocks")) - assert {:error, {:smells_bad, _}} = result - end) - - assert output_stderr =~ "self-link" - end - - test "returns error when no related issues provided" do - assert {:error, {:smells_bad, _}} = - Commands.issue_relation_add(%{ - unknown: ["EXT-1"], - options: %{output: "text", type: "blocks"} - }) - end - - test "returns error when no issue ids provided" do - assert {:error, {:smells_bad, _}} = - Commands.issue_relation_add(%{ - unknown: [], - options: %{output: "text", type: "blocks"} - }) - end - - test "exits non-zero on full failure and prints to stderr" do - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, create_error_response("Unauthorized")) - end) - - stderr = - capture_io(:stderr, fn -> - result = Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "blocks")) - assert {:error, {:smells_bad, msg}} = result - assert msg =~ "failed" - end) - - assert stderr =~ "EXT-2: Linear API error: Unauthorized" - end - - test "partial failure: succeeds for valid targets, errors for failed targets" do - call_count = :counters.new(1, []) - - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - :counters.add(call_count, 1, 1) - %{"variables" => %{"relatedIssueId" => related}} = Jason.decode!(body) - - if related == "EXT-2" do - Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-1", "EXT-2")) - else - Req.Test.json(conn, create_error_response("Unauthorized")) - end - end) - - stderr = - capture_io(:stderr, fn -> - output = - capture_io(fn -> - result = - Commands.issue_relation_add( - add_parse_result("EXT-1", ["EXT-2", "EXT-bad"], "blocks") - ) - - assert {:error, {:smells_bad, _}} = result - end) - - send(self(), {:relation_add_output, output}) - end) - - assert_received {:relation_add_output, output} - assert output =~ "EXT-1 now blocks EXT-2" - assert stderr =~ "EXT-bad: Linear API error: Unauthorized" - assert :counters.get(call_count, 1) == 2 - end - - test "JSON output contains per-target status and relation for success" do - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, create_success_response("r1", "blocks", "EXT-1", "EXT-2")) - end) - - output = - capture_io(fn -> - Commands.issue_relation_add(%{ - unknown: ["EXT-1", "EXT-2"], - options: %{output: "json", type: "blocks"} - }) - end) - - [entry] = Jason.decode!(output) - assert entry["status"] == "created" - assert entry["target"] == "EXT-2" - assert entry["relation"]["type"] == "blocks" - end - - test "JSON output shows exists status for duplicate" do - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, create_duplicate_response()) - end) - - output = - capture_io(fn -> - Commands.issue_relation_add(%{ - unknown: ["EXT-1", "EXT-2"], - options: %{output: "json", type: "blocks"} - }) - end) - - [entry] = Jason.decode!(output) - assert entry["status"] == "exists" - assert entry["target"] == "EXT-2" - end - - test "JSON output shows error status for self-link" do - Req.Test.stub(LinearCli.Api, fn _conn -> raise "should not be called" end) - - output = - capture_io(:stderr, fn -> - output_stdout = - capture_io(fn -> - Commands.issue_relation_add(%{ - unknown: ["EXT-1", "EXT-1"], - options: %{output: "json", type: "blocks"} - }) - end) - - [entry] = Jason.decode!(output_stdout) - assert entry["status"] == "error" - assert entry["target"] == "EXT-1" - assert entry["message"] =~ "self-link" - end) - - assert output == "" - end - - test "JSON output shows actual error message for API failures" do - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, create_error_response("Unauthorized")) - end) - - {_result, output} = - with_io(fn -> - Commands.issue_relation_add(%{ - unknown: ["EXT-1", "EXT-2"], - options: %{output: "json", type: "blocks"} - }) - end) - - [entry] = Jason.decode!(output) - assert entry["status"] == "error" - assert entry["target"] == "EXT-2" - assert entry["message"] =~ "Unauthorized" - end - - test "creates a related relation and prints grammatically correct text" do - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, create_success_response("r1", "related", "EXT-1", "EXT-2")) - end) - - output = - capture_io(fn -> - Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "related")) - end) - - assert output =~ "EXT-1 is now related to EXT-2" - end - - test "creates a duplicate relation and prints grammatically correct text" do - Req.Test.stub(LinearCli.Api, fn conn -> - Req.Test.json(conn, create_success_response("r1", "duplicate", "EXT-1", "EXT-2")) - end) - - output = - capture_io(fn -> - Commands.issue_relation_add(add_parse_result("EXT-1", ["EXT-2"], "duplicate")) - end) - - assert output =~ "EXT-1 is now a duplicate of EXT-2" - end - end - - describe "issue relation remove" do - defp remove_parse_result(subject, related_ids, type) do - %{ - unknown: [subject | related_ids], - options: %{output: "text", type: type} - } - end - - # Builds a stub that returns the given relations for the list query and - # a success response for the delete mutation. - defp remove_relations_stub(out_nodes, inv_nodes) do - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - decoded = Jason.decode!(body) - - cond do - String.contains?(body, "issueRelationDelete") -> - %{"variables" => %{"id" => id}} = decoded - - Req.Test.json(conn, %{ - "data" => %{ - "issueRelationDelete" => %{"success" => true, "entityId" => id} - } - }) - - String.contains?(body, "inverseRelations") -> - Req.Test.json(conn, %{ - "data" => %{ - "issue" => %{ - "inverseRelations" => %{ - "edges" => Enum.map(inv_nodes, &%{"node" => &1, "cursor" => "c"}), - "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} - } - } - } - }) - - true -> - Req.Test.json(conn, %{ - "data" => %{ - "issue" => %{ - "relations" => %{ - "edges" => Enum.map(out_nodes, &%{"node" => &1, "cursor" => "c"}), - "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} - } - } - } - }) - end - end) - end - - defp remove_relation_node(id, type, src_ident, rel_ident) do - %{ - "id" => id, - "type" => type, - "issue" => %{ - "id" => "i-src", - "identifier" => src_ident, - "title" => "#{src_ident} title", - "url" => "https://example.com/#{src_ident}" - }, - "relatedIssue" => %{ - "id" => "i-rel", - "identifier" => rel_ident, - "title" => "#{rel_ident} title", - "url" => "https://example.com/#{rel_ident}" - } - } - end - - test "removes a blocks relation and prints the result" do - remove_relations_stub( - [remove_relation_node("r1", "blocks", "EXT-1", "EXT-2")], - [] - ) - - output = - capture_io(fn -> - assert :ok = - Commands.issue_relation_remove( - remove_parse_result("EXT-1", ["EXT-2"], "blocks") - ) - end) - - assert output =~ "EXT-1 no longer blocks EXT-2" - end - - test "removes a related relation and prints correct text" do - remove_relations_stub( - [remove_relation_node("r1", "related", "EXT-1", "EXT-2")], - [] - ) - - output = - capture_io(fn -> - assert :ok = - Commands.issue_relation_remove( - remove_parse_result("EXT-1", ["EXT-2"], "related") - ) - end) - - assert output =~ "EXT-1 is no longer related to EXT-2" - end - - test "removes a duplicate relation and prints correct text" do - remove_relations_stub( - [remove_relation_node("r1", "duplicate", "EXT-1", "EXT-2")], - [] - ) - - output = - capture_io(fn -> - assert :ok = - Commands.issue_relation_remove( - remove_parse_result("EXT-1", ["EXT-2"], "duplicate") - ) - end) - - assert output =~ "EXT-1 is no longer a duplicate of EXT-2" - end - - test "absent relation is a no-op and returns :ok" do - remove_relations_stub([], []) - - output = - capture_io(fn -> - assert :ok = - Commands.issue_relation_remove( - remove_parse_result("EXT-1", ["EXT-2"], "blocks") - ) - end) - - assert output =~ "not found" - end - - test "blocked-by matches the inbound blocks relation" do - # EXT-3 blocks EXT-1: stored as an inbound blocks relation on EXT-1 - # The relation node from Linear's perspective: issue=EXT-3, relatedIssue=EXT-1 - remove_relations_stub( - [], - [remove_relation_node("r1", "blocks", "EXT-3", "EXT-1")] - ) - - output = - capture_io(fn -> - assert :ok = - Commands.issue_relation_remove( - remove_parse_result("EXT-1", ["EXT-3"], "blocked-by") - ) - end) - - assert output =~ "EXT-3 no longer blocks EXT-1" - end - - test "blocked-by with no matching inbound relation is a no-op" do - remove_relations_stub([], []) - - output = - capture_io(fn -> - assert :ok = - Commands.issue_relation_remove( - remove_parse_result("EXT-1", ["EXT-3"], "blocked-by") - ) - end) - - assert output =~ "not found" - end - - test "processes multiple related issues independently" do - remove_relations_stub( - [ - remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), - remove_relation_node("r2", "blocks", "EXT-1", "EXT-3") - ], - [] - ) - - output = - capture_io(fn -> - assert :ok = - Commands.issue_relation_remove( - remove_parse_result("EXT-1", ["EXT-2", "EXT-3"], "blocks") - ) - end) - - assert output =~ "EXT-1 no longer blocks EXT-2" - assert output =~ "EXT-1 no longer blocks EXT-3" - end - - test "rejects self-link without calling the delete mutation" do - # The list call is allowed; the delete mutation must not be called. - remove_relations_stub([], []) - - output_stderr = - capture_io(:stderr, fn -> - result = - Commands.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-1"], "blocks")) - - assert {:error, {:smells_bad, _}} = result - end) - - assert output_stderr =~ "self-link" - end - - test "returns error when no related issues provided" do - assert {:error, {:smells_bad, _}} = - Commands.issue_relation_remove(%{ - unknown: ["EXT-1"], - options: %{output: "text", type: "blocks"} - }) - end - - test "returns error when no issue ids provided" do - assert {:error, {:smells_bad, _}} = - Commands.issue_relation_remove(%{ - unknown: [], - options: %{output: "text", type: "blocks"} - }) - end - - test "ambiguous match fails that target and lists all matching ids" do - # Two separate blocks relations to EXT-2 (legacy/bug state) - remove_relations_stub( - [ - remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), - remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") - ], - [] - ) - - output_stderr = - capture_io(:stderr, fn -> - result = - capture_io(fn -> - Commands.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-2"], "blocks")) - end) - - _ = result - end) - - assert output_stderr =~ "ambiguous" - assert output_stderr =~ "r1" - assert output_stderr =~ "r2" - end - - test "ambiguous match exits non-zero" do - remove_relations_stub( - [ - remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), - remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") - ], - [] - ) - - {result, _output} = - with_io(fn -> - Commands.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-2"], "blocks")) - end) - - assert {:error, {:smells_bad, msg}} = result - assert msg =~ "failed" - end - - test "partial failure: succeeds for absent target, errors for ambiguous" do - remove_relations_stub( - [ - remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), - remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") - ], - [] - ) - - {result, _output} = - with_io(fn -> - Commands.issue_relation_remove( - remove_parse_result("EXT-1", ["EXT-2", "EXT-3"], "blocks") - ) - end) - - assert {:error, {:smells_bad, _}} = result - end - - test "API error on delete causes that target to fail" do - Req.Test.stub(LinearCli.Api, fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - - if String.contains?(body, "issueRelationDelete") do - Req.Test.json(conn, %{"errors" => [%{"message" => "Unauthorized"}]}) - else - data = - if String.contains?(body, "inverseRelations") do - %{ - "data" => %{ - "issue" => %{ - "inverseRelations" => %{ - "edges" => [], - "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} - } - } - } - } - else - %{ - "data" => %{ - "issue" => %{ - "relations" => %{ - "edges" => [ - %{ - "node" => remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), - "cursor" => "c" - } - ], - "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} - } - } - } - } - end - - Req.Test.json(conn, data) - end - end) - - {result, _output} = - with_io(fn -> - Commands.issue_relation_remove(remove_parse_result("EXT-1", ["EXT-2"], "blocks")) - end) - - assert {:error, {:smells_bad, _}} = result - end - - test "JSON output shows removed status with relation for success" do - remove_relations_stub( - [remove_relation_node("r1", "blocks", "EXT-1", "EXT-2")], - [] - ) - - output = - capture_io(fn -> - Commands.issue_relation_remove(%{ - unknown: ["EXT-1", "EXT-2"], - options: %{output: "json", type: "blocks"} - }) - end) - - [entry] = Jason.decode!(output) - assert entry["status"] == "removed" - assert entry["target"] == "EXT-2" - assert entry["relation"]["type"] == "blocks" - end - - test "JSON output shows absent status for missing relation" do - remove_relations_stub([], []) - - output = - capture_io(fn -> - Commands.issue_relation_remove(%{ - unknown: ["EXT-1", "EXT-2"], - options: %{output: "json", type: "blocks"} - }) - end) - - [entry] = Jason.decode!(output) - assert entry["status"] == "absent" - assert entry["target"] == "EXT-2" - end - - test "JSON output shows error with all ids for ambiguous match" do - remove_relations_stub( - [ - remove_relation_node("r1", "blocks", "EXT-1", "EXT-2"), - remove_relation_node("r2", "blocks", "EXT-1", "EXT-2") - ], - [] - ) - - output = - capture_io(fn -> - Commands.issue_relation_remove(%{ - unknown: ["EXT-1", "EXT-2"], - options: %{output: "json", type: "blocks"} - }) - end) - - [entry] = Jason.decode!(output) - assert entry["status"] == "error" - assert entry["target"] == "EXT-2" - assert entry["message"] =~ "ambiguous" - assert entry["message"] =~ "r1" - assert entry["message"] =~ "r2" - end - - test "JSON output shows error for self-link" do - # The list call is allowed; the delete mutation must not be called. - remove_relations_stub([], []) - - output = - capture_io(:stderr, fn -> - output_stdout = - capture_io(fn -> - Commands.issue_relation_remove(%{ - unknown: ["EXT-1", "EXT-1"], - options: %{output: "json", type: "blocks"} - }) - end) - - [entry] = Jason.decode!(output_stdout) - assert entry["status"] == "error" - assert entry["message"] =~ "self-link" - end) - - assert output == "" - end - end -end From c91f0ec1e7c50b2786a6a1f980d48dc35ef2c613 Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 13:28:50 -0400 Subject: [PATCH 2/3] =?UTF-8?q?refactor(cli):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20binary=20pattern=20matching,=20correct=20module=20m?= =?UTF-8?q?ap,=20relation=20smoke=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace UUID regex + if/else in resolve_bulk_project with two function clauses: binary pattern <<_::8*8, ?-, _::4*8, ...>> for UUID shape, plain name lookup otherwise - Replace if/else in show_move_results with two function clauses keyed on the "json" output mode - Remove false Ruby attribution from Move moduledoc - Fix AGENTS.md module map: split System into System/Profiles/Teams/Projects entries, correct Mutations description to update/status/assign/comment - Add route-level smoke tests for issue relation list/add/remove via LinearCli.CLI.main/1, covering the dispatch paths not exercised by the direct-module tests Co-Authored-By: Claude Sonnet 4.6 --- AGENTS.md | 7 +- .../linear_cli/cli/commands/issues/move.ex | 49 ++++--- .../cli/commands/issues/relations_test.exs | 138 ++++++++++++++++++ 3 files changed, 168 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 85af157..f361e15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,11 +81,14 @@ The app is organized in layers: ``` LinearCli.CLI — Entry point; Optimus argument parsing and dispatch - CLI.Commands.System — system/profile/team/project subcommands + CLI.Commands.System — whoami, version subcommands + CLI.Commands.Profiles — profile create/list/use/show/clear/delete subcommands + 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.Create — issue create subcommand CLI.Commands.Issues.Development — issue branch/PR subcommands - CLI.Commands.Issues.Mutations — issue status/assign/label/comment subcommands + CLI.Commands.Issues.Mutations — issue update/status/assign/comment subcommands CLI.Commands.Issues.Move — issue move subcommand CLI.Commands.Issues.Relations — issue relation list/add/remove subcommands CLI.Issue.Identifiers — issue ID expansion shared across subcommands diff --git a/app/lib/linear_cli/cli/commands/issues/move.ex b/app/lib/linear_cli/cli/commands/issues/move.ex index 2cb0ff0..01e89d2 100644 --- a/app/lib/linear_cli/cli/commands/issues/move.ex +++ b/app/lib/linear_cli/cli/commands/issues/move.ex @@ -1,7 +1,6 @@ defmodule LinearCli.CLI.Commands.Issues.Move do @moduledoc """ Issue move command: moves issues to a target project by ID or in bulk. - Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/move.rb. """ alias LinearCli.CLI.{Display, Projects, Prompt, WhatFor} @@ -127,8 +126,6 @@ defmodule LinearCli.CLI.Commands.Issues.Move do defp validate_issue_ids([]), do: {:error, {:smells_bad, "No issue IDs provided!"}} defp validate_issue_ids(_issue_ids), do: :ok - @uuid_regex ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - defp move_issues_by_project(options, flags) do team_fn = fn -> WhatFor.team_for(options.team || Profiles.default_team()) end @@ -161,20 +158,24 @@ defmodule LinearCli.CLI.Commands.Issues.Move do end end + # UUID by structure: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (8-4-4-4-12, dashes at fixed positions) + defp resolve_bulk_project( + <<_::8*8, ?-, _::4*8, ?-, _::4*8, ?-, _::4*8, ?-, _::12*8>> = uuid, + _team_fn + ) do + short_name = String.slice(uuid, 0, 8) <> "…" + {:ok, struct(LinearCli.Linear.Project, %{id: uuid, name: short_name})} + end + defp resolve_bulk_project(value, team_fn) do - if Regex.match?(@uuid_regex, value) do - short_name = String.slice(value, 0, 8) <> "…" - {:ok, struct(LinearCli.Linear.Project, %{id: value, name: short_name})} + team = team_fn.() + + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: value}), + project when not is_nil(project) <- Projects.project_for(projects, value) do + {:ok, project} else - team = team_fn.() - - with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: value}), - project when not is_nil(project) <- Projects.project_for(projects, value) do - {:ok, project} - else - nil -> {:error, {:smells_bad, "No project found matching #{value}"}} - {:error, reason} -> {:error, reason} - end + nil -> {:error, {:smells_bad, "No project found matching #{value}"}} + {:error, reason} -> {:error, reason} end end @@ -207,17 +208,17 @@ defmodule LinearCli.CLI.Commands.Issues.Move do end) end - defp show_move_results(pairs, source, target, output) do - if output == "json" do - Display.show(one_or_many(Enum.map(pairs, &elem(&1, 1))), %{output: "json"}) - else - Enum.each(pairs, fn {orig, _updated} -> - Prompt.ok("#{orig.identifier} moved to #{target.name}") - end) + defp show_move_results(pairs, _source, _target, "json") do + Display.show(one_or_many(Enum.map(pairs, &elem(&1, 1))), %{output: "json"}) + :ok + end - Prompt.ok("Moved #{length(pairs)} issue(s) from #{source.name} to #{target.name}") - end + defp show_move_results(pairs, source, target, _output) do + Enum.each(pairs, fn {orig, _updated} -> + Prompt.ok("#{orig.identifier} moved to #{target.name}") + end) + Prompt.ok("Moved #{length(pairs)} issue(s) from #{source.name} to #{target.name}") :ok end diff --git a/app/test/linear_cli/cli/commands/issues/relations_test.exs b/app/test/linear_cli/cli/commands/issues/relations_test.exs index a05cd0f..c2fc95a 100644 --- a/app/test/linear_cli/cli/commands/issues/relations_test.exs +++ b/app/test/linear_cli/cli/commands/issues/relations_test.exs @@ -838,4 +838,142 @@ defmodule LinearCli.CLI.Commands.Issues.RelationsTest do assert output == "" end end + + describe "relation dispatch routes (smoke)" do + defp relation_dispatch_stub do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + + cond do + String.contains?(body, "issueRelationCreate") -> + Req.Test.json(conn, %{ + "data" => %{ + "issueRelationCreate" => %{ + "success" => true, + "issueRelation" => %{ + "id" => "r1", + "type" => "blocks", + "issue" => %{ + "id" => "i-src", + "identifier" => "EXT-1", + "title" => "EXT-1 title", + "url" => "https://example.com/EXT-1" + }, + "relatedIssue" => %{ + "id" => "i-rel", + "identifier" => "EXT-2", + "title" => "EXT-2 title", + "url" => "https://example.com/EXT-2" + } + } + } + } + }) + + String.contains?(body, "issueRelationDelete") -> + %{"variables" => %{"id" => id}} = Jason.decode!(body) + + Req.Test.json(conn, %{ + "data" => %{"issueRelationDelete" => %{"success" => true, "entityId" => id}} + }) + + String.contains?(body, "inverseRelations") -> + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "inverseRelations" => %{ + "edges" => [], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + }) + + true -> + Req.Test.json(conn, %{ + "data" => %{ + "issue" => %{ + "relations" => %{ + "edges" => [ + %{ + "node" => %{ + "id" => "r1", + "type" => "blocks", + "issue" => %{ + "id" => "i-src", + "identifier" => "EXT-1", + "title" => "EXT-1 title", + "url" => "u" + }, + "relatedIssue" => %{ + "id" => "i-rel", + "identifier" => "EXT-2", + "title" => "EXT-2 title", + "url" => "u" + } + }, + "cursor" => "c" + } + ], + "pageInfo" => %{"hasNextPage" => false, "endCursor" => nil} + } + } + } + }) + end + end) + end + + test "issue relation list route dispatches to Relations.issue_relation_list/1" do + relation_dispatch_stub() + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "relation", "list", "EXT-1"]) + end) + + assert output =~ "Blocks:" + assert output =~ "EXT-2" + end + + test "issue relation add route dispatches to Relations.issue_relation_add/1" do + relation_dispatch_stub() + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "relation", + "add", + "--type", + "blocks", + "EXT-1", + "EXT-2" + ]) + end) + + assert output =~ "EXT-1 now blocks EXT-2" + end + + test "issue relation remove route dispatches to Relations.issue_relation_remove/1" do + relation_dispatch_stub() + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "relation", + "remove", + "--type", + "blocks", + "EXT-1", + "EXT-2" + ]) + end) + + assert output =~ "EXT-1 no longer blocks EXT-2" + end + end end From d3e9048442d8292900c7a2844405e4a9334e8e4b Mon Sep 17 00:00:00 2001 From: bougyman's bot Date: Sat, 12 Sep 2026 13:40:28 -0400 Subject: [PATCH 3/3] docs(cli): remove false Ruby attribution from Relations moduledoc Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli/commands/issues/relations.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/app/lib/linear_cli/cli/commands/issues/relations.ex b/app/lib/linear_cli/cli/commands/issues/relations.ex index 9a5f4c3..b0c055c 100644 --- a/app/lib/linear_cli/cli/commands/issues/relations.ex +++ b/app/lib/linear_cli/cli/commands/issues/relations.ex @@ -1,7 +1,6 @@ defmodule LinearCli.CLI.Commands.Issues.Relations do @moduledoc """ Issue relation commands: list, add, and remove. - Ported from vendor/ruby-linear-cli/lib/linear/commands/issue/relation.rb. """ alias LinearCli.CLI.{Display, Prompt}