diff --git a/Readme.adoc b/Readme.adoc index 04d9c64..268f390 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -319,8 +319,9 @@ $ lc issue status CRY-1234 -s Done -m "Wrapped up" <4> ==== Move issues to a project -Moves one or more issues to a different Linear project. Prompts for the target -project when `--project`/`-p` is omitted. +Two modes: move specific issues by ID, or bulk-move an entire project's backlog. + +*By ID* — moves the listed issues to a target project: [source,sh] ---- @@ -336,6 +337,22 @@ $ lc issue move --project Manhattan --team ENG CRY-1 <5> <4> Skip the confirmation prompt <5> Scope project search to the `ENG` team +*Bulk project-to-project* (`--from`/`--to`) — moves all open issues from one +project to another. Useful for retiring a project or consolidating backlogs. +Issues keep their original team. + +[source,sh] +---- +$ lc issue move --from Retired --to Active <1> +$ lc issue move --from Retired --to Active --all <2> +$ lc issue move --from p-uuid-1 --to p-uuid-2 <3> +$ lc issue move --from Retired --to Active --dry-run <4> +---- +<1> Moves all open issues from "Retired" to "Active", prompts for confirmation +<2> Includes completed and cancelled issues too +<3> Move by project UUID — the way to target a project in another team +<4> Preview without mutating + ==== Default team/project (profiles) Not in Ruby's `linear-cli` - save a named team/project bundle once, then diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 85092c4..0a4cc87 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -769,9 +769,14 @@ defmodule LinearCli.CLI do ], move: [ name: "move", - about: "Move one or more issues to a project (ISSUE_ID...)", + about: + "Move issues: by ID (ISSUE_ID... --project P) or bulk project-to-project (--from P --to P)", allow_unknown_args: true, flags: [ + all: [ + long: "--all", + help: "Move completed and cancelled issues too (--from/--to mode)" + ], dry_run: [ long: "--dry-run", help: "Preview moves without executing them" @@ -788,6 +793,15 @@ defmodule LinearCli.CLI do long: "--project", help: "Target project name, URL, ID, or - to select from a list" ], + from: [ + short: "-f", + long: "--from", + help: "Source project name or ID (bulk mode)" + ], + to: [ + long: "--to", + help: "Target project name or ID (bulk mode)" + ], team: [ short: "-t", long: "--team", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index f461f0d..45204fa 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -520,20 +520,36 @@ defmodule LinearCli.CLI.Commands do end @doc """ - Moves one or more issues to a target project. - - Issue IDs are captured via `allow_unknown_args: true` (same pattern as - `issue_take/2`/`issue_status/1`/`issue_update/1`). The target project is - resolved from `--project` (fuzzy-match against the team's projects via - `LinearCli.CLI.Projects.project_for/2`) or interactively if omitted. - Team is derived from `--team`, the active profile, or the first fetched - issue's team (to avoid a separate team prompt). - - With `--dry-run`, prints the planned moves without executing any mutations. + 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, &IssueHelpers.expand_issue_id/1)}), @@ -620,6 +636,100 @@ defmodule LinearCli.CLI.Commands 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 + + 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 + @doc """ Changes the workflow state of one or more issues. Optimus captures the IDs in `unknown`, since it has no variadic positional-argument type. diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index cf6ef39..8dcc1d3 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -2684,5 +2684,349 @@ defmodule LinearCli.CLI.IssueCommandsTest do 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