Skip to content

Commit bbb2df3

Browse files
bougymanclaude
andauthored
feat(issue): add --from/--to bulk project-to-project mode to lc issue move (#189)
## Summary Extends the existing `lc issue move` command (which moves explicit issue IDs via `--project`) with a second **bulk project-to-project** mode: `lc issue move --from SOURCE --to TARGET`. - **Optimus spec** updated with `--from`/`--to`/`--all` options alongside the existing `--project`/`--team` (single spec, two modes) - `issue_move/1` dispatches on presence of `--from`/`--to` vs issue IDs — all existing ID-based behavior is unchanged - Bulk mode resolves both projects (UUID used directly; name searched within `--team`), guards against same-project moves, lists open issues from source (`mine: false`, `all: flags.all`), then fans out concurrent mutations (max 20, ordered, 30s timeout) following the `issue_status/1` pattern - Per-issue "moved to" lines + summary count on completion; `--output json` emits an array of updated issue maps; `--dry-run` lists without mutating ## Test plan - [x] 7 new `issue move --from/--to` tests: happy path (3 mutations), `--all` (no date guards in filter), `--dry-run` (no mutations), error mid-batch (non-zero exit), `--output json` (decodable array of 3), UUID bypass (no `$teamId` query), same-ID guard (halts before list query) - [x] All 333 existing tests still pass (ID-based mode unchanged) - [x] `mix ci` passes (compile clean, credo no issues, usage_rules sync, 340/340 tests) Closes EXT-10. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 51cd1f7 commit bbb2df3

4 files changed

Lines changed: 498 additions & 13 deletions

File tree

Readme.adoc

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,9 @@ $ lc issue status CRY-1234 -s Done -m "Wrapped up" <4>
319319

320320
==== Move issues to a project
321321

322-
Moves one or more issues to a different Linear project. Prompts for the target
323-
project when `--project`/`-p` is omitted.
322+
Two modes: move specific issues by ID, or bulk-move an entire project's backlog.
323+
324+
*By ID* — moves the listed issues to a target project:
324325

325326
[source,sh]
326327
----
@@ -336,6 +337,22 @@ $ lc issue move --project Manhattan --team ENG CRY-1 <5>
336337
<4> Skip the confirmation prompt
337338
<5> Scope project search to the `ENG` team
338339

340+
*Bulk project-to-project* (`--from`/`--to`) — moves all open issues from one
341+
project to another. Useful for retiring a project or consolidating backlogs.
342+
Issues keep their original team.
343+
344+
[source,sh]
345+
----
346+
$ lc issue move --from Retired --to Active <1>
347+
$ lc issue move --from Retired --to Active --all <2>
348+
$ lc issue move --from p-uuid-1 --to p-uuid-2 <3>
349+
$ lc issue move --from Retired --to Active --dry-run <4>
350+
----
351+
<1> Moves all open issues from "Retired" to "Active", prompts for confirmation
352+
<2> Includes completed and cancelled issues too
353+
<3> Move by project UUID — the way to target a project in another team
354+
<4> Preview without mutating
355+
339356
==== Default team/project (profiles)
340357

341358
Not in Ruby's `linear-cli` - save a named team/project bundle once, then

app/lib/linear_cli/cli.ex

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,9 +769,14 @@ defmodule LinearCli.CLI do
769769
],
770770
move: [
771771
name: "move",
772-
about: "Move one or more issues to a project (ISSUE_ID...)",
772+
about:
773+
"Move issues: by ID (ISSUE_ID... --project P) or bulk project-to-project (--from P --to P)",
773774
allow_unknown_args: true,
774775
flags: [
776+
all: [
777+
long: "--all",
778+
help: "Move completed and cancelled issues too (--from/--to mode)"
779+
],
775780
dry_run: [
776781
long: "--dry-run",
777782
help: "Preview moves without executing them"
@@ -788,6 +793,15 @@ defmodule LinearCli.CLI do
788793
long: "--project",
789794
help: "Target project name, URL, ID, or - to select from a list"
790795
],
796+
from: [
797+
short: "-f",
798+
long: "--from",
799+
help: "Source project name or ID (bulk mode)"
800+
],
801+
to: [
802+
long: "--to",
803+
help: "Target project name or ID (bulk mode)"
804+
],
791805
team: [
792806
short: "-t",
793807
long: "--team",

app/lib/linear_cli/cli/commands.ex

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -520,20 +520,36 @@ defmodule LinearCli.CLI.Commands do
520520
end
521521

522522
@doc """
523-
Moves one or more issues to a target project.
524-
525-
Issue IDs are captured via `allow_unknown_args: true` (same pattern as
526-
`issue_take/2`/`issue_status/1`/`issue_update/1`). The target project is
527-
resolved from `--project` (fuzzy-match against the team's projects via
528-
`LinearCli.CLI.Projects.project_for/2`) or interactively if omitted.
529-
Team is derived from `--team`, the active profile, or the first fetched
530-
issue's team (to avoid a separate team prompt).
531-
532-
With `--dry-run`, prints the planned moves without executing any mutations.
523+
Moves issues to a target project.
524+
525+
Two modes:
526+
- **ID-based** (EXT-9): `ISSUE_ID... --project P [--team T]` — moves the
527+
listed issues to the named project, resolved per-issue from the issue's
528+
own team or the given `--team`. Concurrent apply, same pattern as
529+
`issue_status/1`.
530+
- **Bulk project-to-project** (Phase 12): `--from P --to P [--team T]` —
531+
lists all open issues (or all with `--all`) from the source project and
532+
fans out mutations to the target project concurrently.
533+
534+
With `--dry-run`, prints the planned moves without mutating.
533535
Without `--yes`, asks for confirmation before applying.
534536
"""
535537
@spec issue_move(Optimus.ParseResult.t()) :: :ok | {:error, term()}
536538
def issue_move(%{unknown: issue_ids, options: options, flags: flags}) do
539+
cond do
540+
options.from && options.to ->
541+
move_issues_by_project(options, flags)
542+
543+
options.from || options.to ->
544+
{:error,
545+
{:smells_bad, "--from and --to must both be given for bulk project-to-project mode"}}
546+
547+
true ->
548+
move_issues_by_id(issue_ids, options, flags)
549+
end
550+
end
551+
552+
defp move_issues_by_id(issue_ids, options, flags) do
537553
with :ok <- validate_issue_ids(issue_ids),
538554
{:ok, issues} <-
539555
Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}),
@@ -620,6 +636,100 @@ defmodule LinearCli.CLI.Commands do
620636
defp validate_issue_ids([]), do: {:error, {:smells_bad, "No issue IDs provided!"}}
621637
defp validate_issue_ids(_issue_ids), do: :ok
622638

639+
@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
640+
641+
defp move_issues_by_project(options, flags) do
642+
team_fn = fn -> WhatFor.team_for(options.team || Profiles.default_team()) end
643+
644+
with {:ok, source} <- resolve_bulk_project(options.from, team_fn),
645+
{:ok, target} <- resolve_bulk_project(options.to, team_fn),
646+
:ok <- guard_different_projects(source, target),
647+
{:ok, issues} <- Linear.issues(%{project_id: source.id, mine: false, all: flags.all}) do
648+
cond do
649+
issues == [] ->
650+
label = if flags.all, do: "issues", else: "open issues"
651+
Prompt.ok("No #{label} in #{source.name} to move")
652+
:ok
653+
654+
flags.dry_run ->
655+
Display.show(one_or_many(issues), %{output: options.output})
656+
Prompt.ok("Would move #{length(issues)} issue(s) from #{source.name} to #{target.name}")
657+
:ok
658+
659+
not flags.yes and
660+
not Prompt.yes?(
661+
"Move #{length(issues)} issue(s) from #{source.name} to #{target.name}?"
662+
) ->
663+
Prompt.warn("Move cancelled")
664+
665+
true ->
666+
with {:ok, pairs} <- apply_project_moves(issues, target) do
667+
show_move_results(pairs, source, target, options.output)
668+
end
669+
end
670+
end
671+
end
672+
673+
defp resolve_bulk_project(value, team_fn) do
674+
if Regex.match?(@uuid_regex, value) do
675+
short_name = String.slice(value, 0, 8) <> "…"
676+
{:ok, struct(LinearCli.Linear.Project, %{id: value, name: short_name})}
677+
else
678+
team = team_fn.()
679+
680+
with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: value}),
681+
project when not is_nil(project) <- Projects.project_for(projects, value) do
682+
{:ok, project}
683+
else
684+
nil -> {:error, {:smells_bad, "No project found matching #{value}"}}
685+
{:error, reason} -> {:error, reason}
686+
end
687+
end
688+
end
689+
690+
defp guard_different_projects(%{id: id}, %{id: id}),
691+
do: {:error, {:smells_bad, "source and target are the same project"}}
692+
693+
defp guard_different_projects(_source, _target), do: :ok
694+
695+
defp apply_project_moves(issues, target) do
696+
issues
697+
|> Task.async_stream(
698+
fn issue ->
699+
case Linear.attach_issue_to_project(issue, target.id) do
700+
{:ok, updated} -> {:ok, {issue, updated}}
701+
{:error, reason} -> {:error, reason}
702+
end
703+
end,
704+
max_concurrency: min(length(issues), @max_concurrent_issue_updates),
705+
ordered: true,
706+
timeout: 30_000
707+
)
708+
|> Enum.reduce_while({:ok, []}, fn
709+
{:ok, {:ok, pair}}, {:ok, acc} -> {:cont, {:ok, [pair | acc]}}
710+
{:ok, {:error, reason}}, {:ok, _acc} -> {:halt, {:error, reason}}
711+
{:exit, reason}, {:ok, _acc} -> {:halt, {:error, {:task_exit, reason}}}
712+
end)
713+
|> then(fn
714+
{:ok, results} -> {:ok, Enum.reverse(results)}
715+
error -> error
716+
end)
717+
end
718+
719+
defp show_move_results(pairs, source, target, output) do
720+
if output == "json" do
721+
Display.show(one_or_many(Enum.map(pairs, &elem(&1, 1))), %{output: "json"})
722+
else
723+
Enum.each(pairs, fn {orig, _updated} ->
724+
Prompt.ok("#{orig.identifier} moved to #{target.name}")
725+
end)
726+
727+
Prompt.ok("Moved #{length(pairs)} issue(s) from #{source.name} to #{target.name}")
728+
end
729+
730+
:ok
731+
end
732+
623733
@doc """
624734
Changes the workflow state of one or more issues. Optimus captures the IDs in
625735
`unknown`, since it has no variadic positional-argument type.

0 commit comments

Comments
 (0)