diff --git a/Readme.adoc b/Readme.adoc index cd4f4c3..04d9c64 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -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` @@ -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 diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 4308044..85092c4 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -103,6 +103,8 @@ defmodule LinearCli.CLI do "dev" => "develop", "l" => "list", "ls" => "list", + "m" => "move", + "mv" => "move", "s" => "status", "st" => "status", "stat" => "status", @@ -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) @@ -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", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index daf8985..f461f0d 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -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 diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index f5e0852..cf6ef39 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -2285,4 +2285,404 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert input["stateId"] == "s-ip" end end + + 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 + end end