Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions Readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
----
Expand All @@ -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
Expand Down
16 changes: 15 additions & 1 deletion app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
130 changes: 120 additions & 10 deletions app/lib/linear_cli/cli/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)}),
Expand Down Expand Up @@ -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.
Expand Down
Loading