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
20 changes: 20 additions & 0 deletions Readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ so you don't have to type the full name every time:
|`issue list` |`l`, `ls`
|`issue status` |`s`, `st`, `stat`
|`issue assign` |`a`
|`issue move` |`m`, `mv`
|`issue update` |`u`
|`issue pr` |`pull-request`
|`team list` |`l`, `ls`
Expand Down Expand Up @@ -316,6 +317,25 @@ $ lc issue status CRY-1234 -s Done -m "Wrapped up" <4>
<3> Move multiple issues at once — updates run concurrently
<4> Add a comment alongside the state change

==== 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.

[source,sh]
----
$ lc issue move --project Manhattan CRY-1 <1>
$ lc issue move -p Manhattan CRY-1 CRY-2 CRY-3 <2>
$ lc issue move --project Manhattan --dry-run CRY-1 <3>
$ lc issue move --project Manhattan --yes CRY-1 <4>
$ lc issue move --project Manhattan --team ENG CRY-1 <5>
----
<1> Move a single issue; prompts for confirmation before executing
<2> Move multiple issues at once — updates run concurrently
<3> Preview the planned moves without executing any mutations
<4> Skip the confirmation prompt
<5> Scope project search to the `ENG` team

==== Default team/project (profiles)

Not in Ruby's `linear-cli` - save a named team/project bundle once, then
Expand Down
31 changes: 31 additions & 0 deletions app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ defmodule LinearCli.CLI do
"dev" => "develop",
"l" => "list",
"ls" => "list",
"m" => "move",
"mv" => "move",
"s" => "status",
"st" => "status",
"stat" => "status",
Expand Down Expand Up @@ -209,6 +211,7 @@ defmodule LinearCli.CLI do
do: run(&Commands.issue_develop/1, result, halt)

defp dispatch([:issue, :pr], result, halt), do: run(&Commands.issue_pr/1, result, halt)
defp dispatch([:issue, :move], result, halt), do: run(&Commands.issue_move/1, result, halt)
defp dispatch([:issue, :take], result, halt), do: run(&Commands.issue_take/1, result, halt)
defp dispatch([:issue, :status], result, halt), do: run(&Commands.issue_status/1, result, halt)
defp dispatch([:issue, :update], result, halt), do: run(&Commands.issue_update/1, result, halt)
Expand Down Expand Up @@ -764,6 +767,34 @@ defmodule LinearCli.CLI do
]
]
],
move: [
name: "move",
about: "Move one or more issues to a project (ISSUE_ID...)",
allow_unknown_args: true,
flags: [
dry_run: [
long: "--dry-run",
help: "Preview moves without executing them"
],
yes: [
short: "-y",
long: "--yes",
help: "Skip confirmation prompt"
]
],
options: [
project: [
short: "-p",
long: "--project",
help: "Target project name, URL, ID, or - to select from a list"
],
team: [
short: "-t",
long: "--team",
help: "Scope project search to this team"
]
]
],
update: [
name: "update",
about: "Update an issue",
Expand Down
98 changes: 98 additions & 0 deletions app/lib/linear_cli/cli/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,104 @@ defmodule LinearCli.CLI.Commands do
end
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.
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
with :ok <- validate_issue_ids(issue_ids),
{:ok, issues} <-
Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.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

Expand Down
Loading