diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index eda877d..6298ac5 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -6,7 +6,7 @@ defmodule LinearCli.CLI.Commands do alias LinearCli.Browser alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt, WhatFor} - alias LinearCli.CLI.Issue.Identifiers + alias LinearCli.CLI.Issue.{Actions, Identifiers} alias LinearCli.{Favorites, Git, Linear, Profiles} @max_concurrent_issue_updates 20 @@ -568,7 +568,7 @@ defmodule LinearCli.CLI.Commands do ] Enum.reduce_while(issues, :ok, fn issue, :ok -> - case IssueHelpers.update_issue(issue, update_opts) do + case Actions.update_issue(issue, update_opts) do :ok -> {:cont, :ok} {:error, reason} -> {:halt, {:error, reason}} end @@ -997,7 +997,7 @@ defmodule LinearCli.CLI.Commands do defp maybe_add_status_comment(_issue, nil), do: :ok defp maybe_add_status_comment(issue, comment) do - case IssueHelpers.issue_comment(issue, comment) do + case Actions.issue_comment(issue, comment) do {:ok, _} -> :ok {:error, reason} -> {:error, reason} end diff --git a/app/lib/linear_cli/cli/issue/actions.ex b/app/lib/linear_cli/cli/issue/actions.ex new file mode 100644 index 0000000..c8ecb33 --- /dev/null +++ b/app/lib/linear_cli/cli/issue/actions.ex @@ -0,0 +1,255 @@ +defmodule LinearCli.CLI.Issue.Actions do + @moduledoc """ + Issue lifecycle mutations — comment, close/cancel, description update, + project attachment/move, and update-dispatch — for an already-loaded issue. + + Extracted from `LinearCli.CLI.IssueHelpers`. Ported originally from + `Rubyists::Linear::CLI::Issue` + (vendor/ruby-linear-cli/lib/linear/commands/issue.rb): `issue_comment`, + `cancel_issue`, `close_issue`, `attach_project`, `update_issue`. + + ## Return convention + + Every public function returns `{:ok, result}` or `{:error, reason}` (never + raises), *except* `update_issue/2`, which normalizes down to + `:ok | {:error, reason}` to match `LinearCli.CLI.run/3`'s command-handler + contract. + + `reason` is either whatever `LinearCli.Api`/an Ash manual action surfaces + (a transport/GraphQL/validation error), or a tagged tuple for user-visible + failures: + + {:error, {:smells_bad, message}} + + where `message` is a human-readable `String.t()`. + + ## Workflow-state resolution + + `cancel_issue/2` and `close_issue/2` delegate to + `LinearCli.CLI.Issue.WorkflowStates` for cancelled/completed state + selection. See that module for the full selection and prompt behavior. + + ## PR dispatch + + `update_issue/2` dispatches to `LinearCli.CLI.IssueHelpers.issue_pr/2` for + the `:pr` option until that function is extracted in a later phase. + """ + + alias LinearCli.CLI.Issue.WorkflowStates + alias LinearCli.CLI.IssueHelpers + alias LinearCli.CLI.{Projects, Prompt, WhatFor} + alias LinearCli.Linear + + @doc """ + Adds a comment to `issue`, resolving `comment` (asking, or opening an + editor, if not already given - via `LinearCli.CLI.WhatFor.comment_for/2`) + first. + + Ported from `CLI::Issue#issue_comment`. + """ + @spec issue_comment(%Linear.Issue{}, String.t() | nil) :: + {:ok, %Linear.Comment{}} | {:error, term()} + def issue_comment(issue, comment) do + body = WhatFor.comment_for(issue, comment) + + case Linear.add_comment(issue.identifier, body) do + {:ok, created} -> + Prompt.ok("Comment added to #{issue.identifier}") + {:ok, created} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Cancels `issue`: comments with a resolved reason, then transitions it to + its team's cancelled workflow state. + + `opts` (Ruby's `**options`, plus this port's `:status`): + + * `:reason` - passed through to `LinearCli.CLI.WhatFor.reason_for/2` + * `:status` - cancelled workflow state name (exact or unique prefix) + * `:trash` - trashes the transitioned issue through `issueArchive` + + Ported from `CLI::Issue#cancel_issue`. + """ + @spec cancel_issue(%Linear.Issue{}, keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()} + def cancel_issue(issue, opts \\ []) do + if issue.state && issue.state.type in ["cancelled", "canceled"] do + Prompt.ok("#{issue.identifier} is already #{issue.state.name}") + {:ok, issue} + else + reason = + WhatFor.reason_for(opts[:reason], four: "cancelling #{issue.identifier} - #{issue.title}") + + with {:ok, _comment} <- issue_comment(issue, reason), + {:ok, cancel_state} <- WorkflowStates.cancelled_state_for(issue, opts[:status]), + {:ok, updated} <- Linear.close_issue(issue, cancel_state.id, %{trash: !!opts[:trash]}) do + Prompt.ok("#{issue.identifier} was cancelled") + {:ok, updated} + end + end + end + + @doc """ + Closes (or, if `opts[:cancel]` is truthy, cancels) `issue`: comments with + a resolved reason, then transitions it to the appropriate workflow state. + + `opts` (Ruby's `**options`, plus this port's `:status`): `:cancel`, + `:reason`, `:status`, `:trash` - same meaning as `cancel_issue/2`'s. + + Ported from `CLI::Issue#close_issue`. Note this has its own internal + cancelled/completed branch (mirroring Ruby exactly) even though + `update_issue/2` never actually reaches it with `opts[:cancel]` truthy - + `update_issue/2` dispatches to `cancel_issue/2` directly for that case, + the same as Ruby does. + """ + @spec close_issue(%Linear.Issue{}, keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()} + def close_issue(issue, opts \\ []) do + cancelled = opts[:cancel] + target_types = if cancelled, do: ["cancelled", "canceled"], else: ["completed"] + done = if cancelled, do: "cancelled", else: "closed" + + if issue.state && issue.state.type in target_types do + Prompt.ok("#{issue.identifier} is already #{issue.state.name}") + {:ok, issue} + else + doing = if cancelled, do: "cancelling", else: "closing" + + reason = + WhatFor.reason_for(opts[:reason], four: "#{doing} *#{issue.identifier} - #{issue.title}*") + + with {:ok, _comment} <- issue_comment(issue, reason), + {:ok, workflow_state} <- state_for(cancelled, issue, opts[:status]), + {:ok, updated} <- + Linear.close_issue(issue, workflow_state.id, %{trash: !!opts[:trash]}) do + Prompt.ok("#{issue.identifier} was #{done}") + {:ok, updated} + end + end + end + + defp state_for(true, issue, status), do: WorkflowStates.cancelled_state_for(issue, status) + defp state_for(_cancelled, issue, status), do: WorkflowStates.completed_state_for(issue, status) + + @doc """ + Moves `issue` to the already-resolved `project`, calling + `LinearCli.Linear.attach_issue_to_project/2` and printing a confirmation. + + Unlike `attach_project/2`, this function takes a pre-resolved + `%LinearCli.Linear.Project{}` struct rather than a search string. Callers + that need to resolve a search string first should use `attach_project/2`, + which delegates here after resolution. + """ + @spec move_issue(%Linear.Issue{}, %Linear.Project{}) :: + {:ok, %Linear.Issue{}} | {:error, term()} + def move_issue(issue, project) do + case Linear.attach_issue_to_project(issue, project.id) do + {:ok, updated} -> + Prompt.ok("#{issue.identifier} was moved to #{project.name}") + {:ok, updated} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Attaches `issue` to a project matched against `project_search` among its + team's projects (`LinearCli.CLI.Projects.project_for/2`, prompting to + disambiguate if needed). + + Ported from `CLI::Issue#attach_project`. Like Ruby, does not guard against + `project_search` matching nothing in an empty project list (`project_for` + returning `nil`) - the same faithfully-ported crash risk Ruby's own + `nil.id` would hit. + + Resolves the project from the search string, then delegates to `move_issue/2`. + """ + @spec attach_project(%Linear.Issue{}, String.t() | nil) :: + {:ok, %Linear.Issue{}} | {:error, term()} + def attach_project(issue, project_search) do + with {:ok, projects} <- + Linear.projects_by_team(issue.team.id, %{search: project_search}) do + project = Projects.project_for(projects, project_search) + move_issue(issue, project) + end + end + + @doc """ + Updates `issue`'s description to `description_input`, resolving it (asking, + or opening an editor, if not already given - via + `LinearCli.CLI.WhatFor.description_for/1`) first. + """ + @spec update_description(%Linear.Issue{}, String.t() | nil) :: + {:ok, %Linear.Issue{}} | {:error, term()} + def update_description(issue, description_input) do + description = WhatFor.description_for(description_input) + + case Linear.update_issue_description(issue, description) do + {:ok, updated} -> + Prompt.ok("#{issue.identifier} description updated") + {:ok, updated} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Dispatches an issue update per whichever of `opts`' keys is set, in Ruby's + exact precedence order: + + 1. `:comment` - always applied first (via `issue_comment/2`) if given, + regardless of anything else + 2. `:close` -> `close_issue/2` + 3. `:cancel` -> `cancel_issue/2` + 4. `:pr` -> `LinearCli.CLI.IssueHelpers.issue_pr/2` + 5. `:project` -> `attach_project/2` + 6. `:description` -> `update_description/2` + 7. otherwise, if only `:comment` was given, stop silently + 8. otherwise, warn "No action taken" and report "not updated" + + Ported from `CLI::Issue#update_issue`. Unlike every other function in this + module, normalizes its result down to `:ok | {:error, reason}` (dropping + the `{:ok, term}` wrapper) to match `LinearCli.CLI.run/3`'s command-handler + contract. + """ + @spec update_issue(%Linear.Issue{}, keyword()) :: :ok | {:error, term()} + def update_issue(issue, opts \\ []) do + with :ok <- maybe_comment(issue, opts[:comment]) do + dispatch_update(issue, opts) + end + end + + defp maybe_comment(_issue, nil), do: :ok + + defp maybe_comment(issue, comment) do + case issue_comment(issue, comment) do + {:ok, _comment} -> :ok + {:error, reason} -> {:error, reason} + end + end + + defp dispatch_update(issue, opts) do + cond do + opts[:close] -> normalize(close_issue(issue, opts)) + opts[:cancel] -> normalize(cancel_issue(issue, opts)) + opts[:pr] -> IssueHelpers.issue_pr(issue, opts) + opts[:project] -> normalize(attach_project(issue, opts[:project])) + opts[:description] -> normalize(update_description(issue, opts[:description])) + opts[:comment] -> :ok + true -> no_action_taken() + end + end + + defp no_action_taken do + Prompt.warn("No action taken, no options specified") + Prompt.ok("Issue was not updated") + :ok + end + + defp normalize({:ok, _result}), do: :ok + defp normalize({:error, reason}), do: {:error, reason} +end diff --git a/app/lib/linear_cli/cli/issue_helpers.ex b/app/lib/linear_cli/cli/issue_helpers.ex index 03f53dd..3752eb5 100644 --- a/app/lib/linear_cli/cli/issue_helpers.ex +++ b/app/lib/linear_cli/cli/issue_helpers.ex @@ -1,62 +1,39 @@ defmodule LinearCli.CLI.IssueHelpers do @moduledoc """ - Shared issue-command helpers - comment, close, cancel, open a PR, attach a - project, dispatch an update, create, self-assign. + Shared issue-command helpers - open a PR, create, self-assign. Ported from `Rubyists::Linear::CLI::Issue` - (vendor/ruby-linear-cli/lib/linear/commands/issue.rb): `issue_comment`, - `cancel_issue`, `close_issue`, `create_pr!`, `issue_pr`, `attach_project`, - `update_issue`, `make_da_issue!`, `gimme_da_issue!`. + (vendor/ruby-linear-cli/lib/linear/commands/issue.rb): `create_pr!`, + `issue_pr`, `make_da_issue!`, `gimme_da_issue!`. + + Lifecycle mutations (comment, close/cancel, description update, project + attachment/move, and update-dispatch) have been extracted to + `LinearCli.CLI.Issue.Actions`. Bare-ID expansion lives in + `LinearCli.CLI.Issue.Identifiers`. Workflow-state selection lives in + `LinearCli.CLI.Issue.WorkflowStates`. ## Return convention Every function here returns `{:ok, result}` or `{:error, reason}` (never - raises), *except* `update_issue/2`, which normalizes down to - `:ok | {:error, reason}` to match this codebase's CLI dispatch contract - (`LinearCli.CLI.run/3`, which expects exactly that shape from a command - handler) - it's the one function here a later phase is likely to wire - directly to a subcommand. + raises). `reason` is either whatever `LinearCli.Api`/an Ash manual action already surfaces (a transport/GraphQL/validation error - a genuine system - failure), or a new tagged tuple this module introduces for "the user gave - us something we can't act on, tell them clearly" cases, mirroring Ruby's - `SmellsBad` exception (`vendor/ruby-linear-cli/lib/linear/exceptions.rb`, - raised e.g. by `CLI::SubCommands#ask_for_team` when no team is found): + failure), or a tagged tuple for "the user gave us something we can't act + on, tell them clearly" cases, mirroring Ruby's `SmellsBad` exception: {:error, {:smells_bad, message}} - where `message` is a human-readable `String.t()`. The one case this module - itself raises it: `cancelled_state_for/1`/`completed_state_for/1` finding - *zero* matching workflow states for an issue's team (Ruby's own - `cancelled_states.first`/`completed_states.first` would silently return - `nil` there and blow up two calls later inside `close!`'s GraphQL - round-trip instead - this port catches it at the source with a clear - message). A later phase wiring this into `LinearCli.CLI.main/2`'s - dispatch can add a `handle_error` clause matching `{:smells_bad, message}` - and print `message` + `halt.(22)`, mirroring Ruby's `CLI::Caller#call` - `rescue SmellsBad` clause (which maps to exit code 22). - - ## Workflow-state helpers - - `cancel_issue/2` and `close_issue/2` delegate to - `LinearCli.CLI.Issue.WorkflowStates.cancelled_state_for/2` and - `completed_state_for/2` respectively. See that module for the ported - Ruby logic and the rationale for combining both layers there. + where `message` is a human-readable `String.t()`. ## Project lookups - `attach_project/2` (Ruby: `issue.team.projects`) and `make_da_issue!/1` - (Ruby: `team.projects`) both need a team's projects. Neither + `make_da_issue!/1` (Ruby: `team.projects`) needs a team's projects. Neither `LinearCli.Linear.Issue` nor `LinearCli.Linear.Team` stores a `:projects` field on their structs (Team's own GraphQL `full_fields/0` embeds a `projects` sub-selection, but `Team.from_map/1` never parses it into an - attribute - there's nowhere on the struct to put it), so both call the new - `LinearCli.Linear.projects_by_team/1` domain interface instead (added - alongside this module, wrapping the pre-existing - `LinearCli.Linear.Project` `:by_team` action the same way - `labels_by_team/1`/`workflow_states_by_team/1` already wrap their own - `:by_team` actions). + attribute - there's nowhere on the struct to put it), so it calls + `LinearCli.Linear.projects_by_team/1` domain interface instead. ## `create_pr!/3` @@ -76,99 +53,6 @@ defmodule LinearCli.CLI.IssueHelpers do alias LinearCli.CLI.{Projects, Prompt, WhatFor} alias LinearCli.{Linear, Profiles} - @doc """ - Adds a comment to `issue`, resolving `comment` (asking, or opening an - editor, if not already given - via `LinearCli.CLI.WhatFor.comment_for/2`) - first. - - Ported from `CLI::Issue#issue_comment`. - """ - @spec issue_comment(%Linear.Issue{}, String.t() | nil) :: - {:ok, %Linear.Comment{}} | {:error, term()} - def issue_comment(issue, comment) do - body = WhatFor.comment_for(issue, comment) - - case Linear.add_comment(issue.identifier, body) do - {:ok, created} -> - Prompt.ok("Comment added to #{issue.identifier}") - {:ok, created} - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Cancels `issue`: comments with a resolved reason, then transitions it to - its team's cancelled workflow state. - - `opts` (Ruby's `**options`, plus this port's `:status`): - - * `:reason` - passed through to `LinearCli.CLI.WhatFor.reason_for/2` - * `:status` - cancelled workflow state name (exact or unique prefix) - * `:trash` - trashes the transitioned issue through `issueArchive` - - Ported from `CLI::Issue#cancel_issue`. - """ - @spec cancel_issue(%Linear.Issue{}, keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()} - def cancel_issue(issue, opts \\ []) do - if issue.state && issue.state.type in ["cancelled", "canceled"] do - Prompt.ok("#{issue.identifier} is already #{issue.state.name}") - {:ok, issue} - else - reason = - WhatFor.reason_for(opts[:reason], four: "cancelling #{issue.identifier} - #{issue.title}") - - with {:ok, _comment} <- issue_comment(issue, reason), - {:ok, cancel_state} <- WorkflowStates.cancelled_state_for(issue, opts[:status]), - {:ok, updated} <- Linear.close_issue(issue, cancel_state.id, %{trash: !!opts[:trash]}) do - Prompt.ok("#{issue.identifier} was cancelled") - {:ok, updated} - end - end - end - - @doc """ - Closes (or, if `opts[:cancel]` is truthy, cancels) `issue`: comments with - a resolved reason, then transitions it to the appropriate workflow state. - - `opts` (Ruby's `**options`, plus this port's `:status`): `:cancel`, - `:reason`, `:status`, `:trash` - same meaning as `cancel_issue/2`'s. - - Ported from `CLI::Issue#close_issue`. Note this has its own internal - cancelled/completed branch (mirroring Ruby exactly) even though - `update_issue/2` never actually reaches it with `opts[:cancel]` truthy - - `update_issue/2` dispatches to `cancel_issue/2` directly for that case, - the same as Ruby does. - """ - @spec close_issue(%Linear.Issue{}, keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()} - def close_issue(issue, opts \\ []) do - cancelled = opts[:cancel] - target_types = if cancelled, do: ["cancelled", "canceled"], else: ["completed"] - done = if cancelled, do: "cancelled", else: "closed" - - if issue.state && issue.state.type in target_types do - Prompt.ok("#{issue.identifier} is already #{issue.state.name}") - {:ok, issue} - else - doing = if cancelled, do: "cancelling", else: "closing" - - reason = - WhatFor.reason_for(opts[:reason], four: "#{doing} *#{issue.identifier} - #{issue.title}*") - - with {:ok, _comment} <- issue_comment(issue, reason), - {:ok, workflow_state} <- state_for(cancelled, issue, opts[:status]), - {:ok, updated} <- - Linear.close_issue(issue, workflow_state.id, %{trash: !!opts[:trash]}) do - Prompt.ok("#{issue.identifier} was #{done}") - {:ok, updated} - end - end - end - - defp state_for(true, issue, status), do: WorkflowStates.cancelled_state_for(issue, status) - defp state_for(_cancelled, issue, status), do: WorkflowStates.completed_state_for(issue, status) - @doc """ Shells out to `gh pr create -a @me --title TITLE --body BODY`, returning whatever the command printed to stdout (Ruby's backtick-captured output - @@ -217,125 +101,6 @@ defmodule LinearCli.CLI.IssueHelpers do :ok end - @doc """ - Moves `issue` to the already-resolved `project`, calling - `LinearCli.Linear.attach_issue_to_project/2` and printing a confirmation. - - Unlike `attach_project/2`, this function takes a pre-resolved - `%LinearCli.Linear.Project{}` struct rather than a search string. Callers - that need to resolve a search string first should use `attach_project/2`, - which delegates here after resolution. - """ - @spec move_issue(%Linear.Issue{}, %Linear.Project{}) :: - {:ok, %Linear.Issue{}} | {:error, term()} - def move_issue(issue, project) do - case Linear.attach_issue_to_project(issue, project.id) do - {:ok, updated} -> - Prompt.ok("#{issue.identifier} was moved to #{project.name}") - {:ok, updated} - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Attaches `issue` to a project matched against `project_search` among its - team's projects (`LinearCli.CLI.Projects.project_for/2`, prompting to - disambiguate if needed). - - Ported from `CLI::Issue#attach_project`. Like Ruby, does not guard against - `project_search` matching nothing in an empty project list (`project_for` - returning `nil`) - the same faithfully-ported crash risk Ruby's own - `nil.id` would hit. - - Resolves the project from the search string, then delegates to `move_issue/2`. - """ - @spec attach_project(%Linear.Issue{}, String.t() | nil) :: - {:ok, %Linear.Issue{}} | {:error, term()} - def attach_project(issue, project_search) do - with {:ok, projects} <- - Linear.projects_by_team(issue.team.id, %{search: project_search}) do - project = Projects.project_for(projects, project_search) - move_issue(issue, project) - end - end - - @doc """ - Updates `issue`'s description to `description_input`, resolving it (asking, - or opening an editor, if not already given - via - `LinearCli.CLI.WhatFor.description_for/1`) first. - """ - @spec update_description(%Linear.Issue{}, String.t() | nil) :: - {:ok, %Linear.Issue{}} | {:error, term()} - def update_description(issue, description_input) do - description = WhatFor.description_for(description_input) - - case Linear.update_issue_description(issue, description) do - {:ok, updated} -> - Prompt.ok("#{issue.identifier} description updated") - {:ok, updated} - - {:error, reason} -> - {:error, reason} - end - end - - @doc """ - Dispatches an issue update per whichever of `opts`' keys is set, in Ruby's - exact precedence order: - - 1. `:comment` - always applied first (via `issue_comment/2`) if given, - regardless of anything else - 2. `:close` -> `close_issue/2` - 3. `:cancel` -> `cancel_issue/2` - 4. `:pr` -> `issue_pr/2` - 5. `:project` -> `attach_project/2` - 6. otherwise, if only `:comment` was given, stop silently - 7. otherwise, warn "No action taken" and report "not updated" - - Ported from `CLI::Issue#update_issue`. Unlike every other function in this - module, normalizes its result down to `:ok | {:error, reason}` (dropping - the `{:ok, term}` wrapper) to match `LinearCli.CLI.run/3`'s command-handler - contract - see this module's moduledoc. - """ - @spec update_issue(%Linear.Issue{}, keyword()) :: :ok | {:error, term()} - def update_issue(issue, opts \\ []) do - with :ok <- maybe_comment(issue, opts[:comment]) do - dispatch_update(issue, opts) - end - end - - defp maybe_comment(_issue, nil), do: :ok - - defp maybe_comment(issue, comment) do - case issue_comment(issue, comment) do - {:ok, _comment} -> :ok - {:error, reason} -> {:error, reason} - end - end - - defp dispatch_update(issue, opts) do - cond do - opts[:close] -> normalize(close_issue(issue, opts)) - opts[:cancel] -> normalize(cancel_issue(issue, opts)) - opts[:pr] -> issue_pr(issue, opts) - opts[:project] -> normalize(attach_project(issue, opts[:project])) - opts[:description] -> normalize(update_description(issue, opts[:description])) - opts[:comment] -> :ok - true -> no_action_taken() - end - end - - defp no_action_taken do - Prompt.warn("No action taken, no options specified") - Prompt.ok("Issue was not updated") - :ok - end - - defp normalize({:ok, _result}), do: :ok - defp normalize({:error, reason}), do: {:error, reason} - @doc """ Creates a new issue, resolving every field that wasn't already given in `opts` interactively (title, description, team, labels, project - via diff --git a/app/test/linear_cli/cli/issue/actions_test.exs b/app/test/linear_cli/cli/issue/actions_test.exs new file mode 100644 index 0000000..b634fbf --- /dev/null +++ b/app/test/linear_cli/cli/issue/actions_test.exs @@ -0,0 +1,386 @@ +defmodule LinearCli.CLI.Issue.ActionsTest do + use ExUnit.Case, async: true + import ExUnit.CaptureIO + + alias LinearCli.CLI.Issue.Actions + alias LinearCli.Linear.{Comment, Issue, Project, Team, WorkflowState} + + defp issue(attrs \\ %{}) do + struct!( + %Issue{ + id: "i1", + identifier: "CRY-1", + title: "Fix the thing", + description: "It is broken", + team: %Team{id: "t1", key: "ENG", name: "Engineering"} + }, + attrs + ) + end + + defp stub_responses(pairs) do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + case Enum.find(pairs, fn {match, _resp} -> String.contains?(query, match) end) do + {_match, response} -> Req.Test.json(conn, response) + nil -> raise "no stub matched query: #{query}" + end + end) + end + + defp comment_created(id \\ "c1") do + %{"data" => %{"commentCreate" => %{"comment" => %{"id" => id, "body" => "x", "url" => "u"}}}} + end + + defp issue_updated(overrides \\ %{}) do + issue_map = + Map.merge( + %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix the thing", + "branchName" => "cry-1-fix-the-thing", + "description" => "It is broken", + "assignee" => nil, + "team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "comments" => %{"nodes" => []} + }, + overrides + ) + + %{"data" => %{"issueUpdate" => %{"issue" => issue_map}}} + end + + defp workflow_states(states) do + %{"data" => %{"team" => %{"states" => %{"nodes" => states}}}} + end + + defp team_projects(projects) do + %{"data" => %{"team" => %{"projects" => %{"nodes" => projects}}}} + end + + defp errors(message) do + %{"errors" => [%{"message" => message}]} + end + + describe "issue_comment/2 (Ruby: CLI::Issue#issue_comment)" do + test "adds the comment and prints a confirmation" do + stub_responses([{"commentCreate", comment_created()}]) + + assert capture_io(fn -> + assert {:ok, %Comment{id: "c1"}} = Actions.issue_comment(issue(), "lgtm") + end) =~ "Comment added to CRY-1" + end + + test "propagates the underlying error without printing anything" do + stub_responses([{"commentCreate", errors("boom")}]) + + assert capture_io(fn -> + assert {:error, %Ash.Error.Unknown{}} = Actions.issue_comment(issue(), "x") + end) == "" + end + end + + describe "cancel_issue/2 (Ruby: CLI::Issue#cancel_issue)" do + test "comments, resolves the cancelled state, and transitions the issue" do + stub_responses([ + {"commentCreate", comment_created()}, + {"states {", + workflow_states([ + %{"id" => "s1", "name" => "Cancelled", "position" => 1.0, "type" => "cancelled"} + ])}, + {"issueUpdate", issue_updated()} + ]) + + output = + capture_io(fn -> + assert {:ok, %Issue{identifier: "CRY-1"}} = + Actions.cancel_issue(issue(), reason: "no longer needed") + end) + + assert output =~ "Comment added to CRY-1" + assert output =~ "CRY-1 was cancelled" + end + + test "surfaces the smells_bad error instead of attempting the transition" do + stub_responses([ + {"commentCreate", comment_created()}, + {"states {", workflow_states([])} + ]) + + assert capture_io(fn -> + assert {:error, {:smells_bad, _message}} = + Actions.cancel_issue(issue(), reason: "no longer needed") + end) =~ "Comment added to CRY-1" + end + + test "is a no-op when the issue is already in a cancelled state" do + already_cancelled = + issue(%{state: %WorkflowState{id: "s1", name: "Cancelled", type: "cancelled"}}) + + output = + capture_io(fn -> + assert {:ok, ^already_cancelled} = + Actions.cancel_issue(already_cancelled, reason: "no longer needed") + end) + + assert output =~ "CRY-1 is already Cancelled" + refute output =~ "Comment added" + end + end + + describe "close_issue/2 (Ruby: CLI::Issue#close_issue)" do + test "closes (completed state) by default" do + stub_responses([ + {"commentCreate", comment_created()}, + {"states {", + workflow_states([ + %{"id" => "s1", "name" => "Done", "position" => 1.0, "type" => "completed"} + ])}, + {"issueUpdate", issue_updated()} + ]) + + output = + capture_io(fn -> + assert {:ok, %Issue{}} = Actions.close_issue(issue(), reason: "shipped") + end) + + assert output =~ "CRY-1 was closed" + end + + test "cancels (cancelled state) when opts[:cancel] is truthy" do + stub_responses([ + {"commentCreate", comment_created()}, + {"states {", + workflow_states([ + %{"id" => "s1", "name" => "Cancelled", "position" => 1.0, "type" => "cancelled"} + ])}, + {"issueUpdate", issue_updated()} + ]) + + output = + capture_io(fn -> + assert {:ok, %Issue{}} = + Actions.close_issue(issue(), cancel: true, reason: "nope") + end) + + assert output =~ "CRY-1 was cancelled" + end + + test "is a no-op when the issue is already in a completed state" do + already_done = issue(%{state: %WorkflowState{id: "s1", name: "Done", type: "completed"}}) + + output = + capture_io(fn -> + assert {:ok, ^already_done} = Actions.close_issue(already_done, reason: "shipped") + end) + + assert output =~ "CRY-1 is already Done" + refute output =~ "Comment added" + end + + test "is a no-op when cancel: true and issue is already in a cancelled state" do + already_cancelled = + issue(%{state: %WorkflowState{id: "s1", name: "Cancelled", type: "cancelled"}}) + + output = + capture_io(fn -> + assert {:ok, ^already_cancelled} = + Actions.close_issue(already_cancelled, cancel: true, reason: "nope") + end) + + assert output =~ "CRY-1 is already Cancelled" + refute output =~ "Comment added" + end + end + + describe "update_description/2" do + test "resolves and sends the description, printing a confirmation" do + stub_responses([{"issueUpdate", issue_updated(%{"description" => "New body"})}]) + + assert capture_io(fn -> + assert {:ok, %Issue{description: "New body"}} = + Actions.update_description(issue(), "New body") + end) =~ "CRY-1 description updated" + end + + test "propagates an API error without printing confirmation" do + stub_responses([{"issueUpdate", %{"errors" => [%{"message" => "boom"}]}}]) + + assert capture_io(fn -> + assert {:error, %Ash.Error.Invalid{}} = + Actions.update_description(issue(), "New body") + end) == "" + end + end + + describe "move_issue/2" do + test "moves the issue to the resolved project and prints a confirmation" do + stub_responses([{"issueUpdate", issue_updated()}]) + + project = %Project{id: "p1", name: "Manhattan Rollout"} + + assert capture_io(fn -> + assert {:ok, %Issue{}} = Actions.move_issue(issue(), project) + end) =~ "CRY-1 was moved to Manhattan Rollout" + end + + test "propagates an API error without printing confirmation" do + stub_responses([{"issueUpdate", %{"errors" => [%{"message" => "boom"}]}}]) + + project = %Project{id: "p1", name: "Manhattan Rollout"} + + assert capture_io(fn -> + assert {:error, %Ash.Error.Invalid{}} = Actions.move_issue(issue(), project) + end) == "" + end + end + + describe "attach_project/2 (Ruby: CLI::Issue#attach_project)" do + test "resolves the project by name against the team's projects and attaches it" do + stub_responses([ + {"projects(first: 100", + team_projects([ + %{ + "id" => "p1", + "name" => "Manhattan Rollout", + "content" => nil, + "slugId" => "abc", + "description" => nil, + "url" => "https://linear.app/x/project/manhattan-rollout-abc" + } + ])}, + {"issueUpdate", issue_updated()} + ]) + + assert capture_io(fn -> + assert {:ok, %Issue{}} = + Actions.attach_project(issue(), "Manhattan Rollout") + end) =~ "CRY-1 was moved to Manhattan Rollout" + end + end + + describe "update_issue/2 dispatch (Ruby: CLI::Issue#update_issue)" do + test "with :close, comments then closes" do + stub_responses([ + {"commentCreate", comment_created()}, + {"states {", + workflow_states([ + %{"id" => "s1", "name" => "Done", "position" => 1.0, "type" => "completed"} + ])}, + {"issueUpdate", issue_updated()} + ]) + + output = + capture_io(fn -> + assert :ok = Actions.update_issue(issue(), close: true, reason: "done") + end) + + assert output =~ "CRY-1 was closed" + end + + test "with :cancel, comments then cancels" do + stub_responses([ + {"commentCreate", comment_created()}, + {"states {", + workflow_states([ + %{"id" => "s1", "name" => "Cancelled", "position" => 1.0, "type" => "cancelled"} + ])}, + {"issueUpdate", issue_updated()} + ]) + + output = + capture_io(fn -> + assert :ok = Actions.update_issue(issue(), cancel: true, reason: "nope") + end) + + assert output =~ "CRY-1 was cancelled" + end + + test "with :pr, opens a PR via the injectable runner and never calls the API" do + output = + capture_io(fn -> + assert :ok = + Actions.update_issue(issue(), + pr: true, + title: "fix: CRY-1 - Fix the thing", + description: "body", + runner: fn _title, _body -> "https://github.com/x/y/pull/1" end + ) + end) + + assert output =~ "https://github.com/x/y/pull/1" + end + + test "with :project, resolves and attaches" do + stub_responses([ + {"projects(first: 100", + team_projects([ + %{ + "id" => "p1", + "name" => "Manhattan Rollout", + "content" => nil, + "slugId" => "abc", + "description" => nil, + "url" => "https://linear.app/x/project/manhattan-rollout-abc" + } + ])}, + {"issueUpdate", issue_updated()} + ]) + + output = + capture_io(fn -> + assert :ok = Actions.update_issue(issue(), project: "Manhattan Rollout") + end) + + assert output =~ "CRY-1 was moved to Manhattan Rollout" + end + + test "with :description, updates the issue description" do + stub_responses([{"issueUpdate", issue_updated(%{"description" => "New body"})}]) + + output = + capture_io(fn -> + assert :ok = Actions.update_issue(issue(), description: "New body") + end) + + assert output =~ "CRY-1 description updated" + end + + test "with only :comment, comments and stops without the 'no action taken' warning" do + stub_responses([{"commentCreate", comment_created()}]) + + output = + capture_io(fn -> + assert :ok = Actions.update_issue(issue(), comment: "fyi") + end) + + assert output =~ "Comment added to CRY-1" + refute output =~ "No action taken" + end + + test "with no options at all, warns and reports no update, without calling the API" do + output = + capture_io(fn -> + assert :ok = Actions.update_issue(issue()) + end) + + assert output =~ "No action taken, no options specified" + assert output =~ "Issue was not updated" + end + + test "an error from a dispatched action propagates as {:error, reason}" do + stub_responses([ + {"commentCreate", comment_created()}, + {"states {", errors("boom")} + ]) + + assert capture_io(fn -> + assert {:error, %Ash.Error.Unknown{}} = + Actions.update_issue(issue(), close: true, reason: "x") + end) =~ "Comment added to CRY-1" + end + end +end diff --git a/app/test/linear_cli/cli/issue_helpers_test.exs b/app/test/linear_cli/cli/issue_helpers_test.exs index fce2612..9e05f72 100644 --- a/app/test/linear_cli/cli/issue_helpers_test.exs +++ b/app/test/linear_cli/cli/issue_helpers_test.exs @@ -3,12 +3,8 @@ defmodule LinearCli.CLI.IssueHelpersTest do import ExUnit.CaptureIO alias LinearCli.CLI.IssueHelpers - alias LinearCli.Linear.{Comment, Issue, Project, Team, User, WorkflowState} + alias LinearCli.Linear.{Issue, Team, User} - # Every helper under test accepts an already-loaded resource struct (no - # data-layer fetch happens inside these functions themselves, mirroring - # `assign_issue/2`/`close_issue/2`/etc.'s own documented contract) - so - # tests build issues/teams directly instead of stubbing a lookup for them. defp issue(attrs \\ %{}) do struct!( %Issue{ @@ -22,12 +18,6 @@ defmodule LinearCli.CLI.IssueHelpersTest do ) end - # Dispatches to one of `pairs` ({substring, response_map}) based on which - # substring appears in the outgoing GraphQL document - every document in - # this codebase has a distinguishing operation name/field - # (`commentCreate`, `issueUpdate`, `states {`, `projects(first: 100`, - # `issueCreate`, `viewer`, `issue(id: $id)`), so one stub per test can - # drive an entire multi-call flow. defp stub_responses(pairs) do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) @@ -40,11 +30,7 @@ defmodule LinearCli.CLI.IssueHelpersTest do end) end - defp comment_created(id \\ "c1") do - %{"data" => %{"commentCreate" => %{"comment" => %{"id" => id, "body" => "x", "url" => "u"}}}} - end - - defp issue_updated(overrides \\ %{}) do + defp issue_updated(overrides) do issue_map = Map.merge( %{ @@ -63,337 +49,10 @@ defmodule LinearCli.CLI.IssueHelpersTest do %{"data" => %{"issueUpdate" => %{"issue" => issue_map}}} end - defp workflow_states(states) do - %{"data" => %{"team" => %{"states" => %{"nodes" => states}}}} - end - defp team_projects(projects) do %{"data" => %{"team" => %{"projects" => %{"nodes" => projects}}}} end - defp errors(message) do - %{"errors" => [%{"message" => message}]} - end - - describe "issue_comment/2 (Ruby: CLI::Issue#issue_comment)" do - test "adds the comment and prints a confirmation" do - stub_responses([{"commentCreate", comment_created()}]) - - assert capture_io(fn -> - assert {:ok, %Comment{id: "c1"}} = IssueHelpers.issue_comment(issue(), "lgtm") - end) =~ "Comment added to CRY-1" - end - - test "propagates the underlying error without printing anything" do - stub_responses([{"commentCreate", errors("boom")}]) - - assert capture_io(fn -> - assert {:error, %Ash.Error.Unknown{}} = IssueHelpers.issue_comment(issue(), "x") - end) == "" - end - end - - describe "cancel_issue/2 (Ruby: CLI::Issue#cancel_issue)" do - test "comments, resolves the cancelled state, and transitions the issue" do - stub_responses([ - {"commentCreate", comment_created()}, - {"states {", - workflow_states([ - %{"id" => "s1", "name" => "Cancelled", "position" => 1.0, "type" => "cancelled"} - ])}, - {"issueUpdate", issue_updated()} - ]) - - output = - capture_io(fn -> - assert {:ok, %Issue{identifier: "CRY-1"}} = - IssueHelpers.cancel_issue(issue(), reason: "no longer needed") - end) - - assert output =~ "Comment added to CRY-1" - assert output =~ "CRY-1 was cancelled" - end - - test "surfaces the smells_bad error instead of attempting the transition" do - stub_responses([ - {"commentCreate", comment_created()}, - {"states {", workflow_states([])} - ]) - - assert capture_io(fn -> - assert {:error, {:smells_bad, _message}} = - IssueHelpers.cancel_issue(issue(), reason: "no longer needed") - end) =~ "Comment added to CRY-1" - end - - test "is a no-op when the issue is already in a cancelled state" do - already_cancelled = - issue(%{state: %WorkflowState{id: "s1", name: "Cancelled", type: "cancelled"}}) - - output = - capture_io(fn -> - assert {:ok, ^already_cancelled} = - IssueHelpers.cancel_issue(already_cancelled, reason: "no longer needed") - end) - - assert output =~ "CRY-1 is already Cancelled" - refute output =~ "Comment added" - end - end - - describe "close_issue/2 (Ruby: CLI::Issue#close_issue)" do - test "closes (completed state) by default" do - stub_responses([ - {"commentCreate", comment_created()}, - {"states {", - workflow_states([ - %{"id" => "s1", "name" => "Done", "position" => 1.0, "type" => "completed"} - ])}, - {"issueUpdate", issue_updated()} - ]) - - output = - capture_io(fn -> - assert {:ok, %Issue{}} = IssueHelpers.close_issue(issue(), reason: "shipped") - end) - - assert output =~ "CRY-1 was closed" - end - - test "cancels (cancelled state) when opts[:cancel] is truthy" do - stub_responses([ - {"commentCreate", comment_created()}, - {"states {", - workflow_states([ - %{"id" => "s1", "name" => "Cancelled", "position" => 1.0, "type" => "cancelled"} - ])}, - {"issueUpdate", issue_updated()} - ]) - - output = - capture_io(fn -> - assert {:ok, %Issue{}} = - IssueHelpers.close_issue(issue(), cancel: true, reason: "nope") - end) - - assert output =~ "CRY-1 was cancelled" - end - - test "is a no-op when the issue is already in a completed state" do - already_done = issue(%{state: %WorkflowState{id: "s1", name: "Done", type: "completed"}}) - - output = - capture_io(fn -> - assert {:ok, ^already_done} = IssueHelpers.close_issue(already_done, reason: "shipped") - end) - - assert output =~ "CRY-1 is already Done" - refute output =~ "Comment added" - end - - test "is a no-op when cancel: true and issue is already in a cancelled state" do - already_cancelled = - issue(%{state: %WorkflowState{id: "s1", name: "Cancelled", type: "cancelled"}}) - - output = - capture_io(fn -> - assert {:ok, ^already_cancelled} = - IssueHelpers.close_issue(already_cancelled, cancel: true, reason: "nope") - end) - - assert output =~ "CRY-1 is already Cancelled" - refute output =~ "Comment added" - end - end - - describe "update_description/2" do - test "resolves and sends the description, printing a confirmation" do - stub_responses([{"issueUpdate", issue_updated(%{"description" => "New body"})}]) - - assert capture_io(fn -> - assert {:ok, %Issue{description: "New body"}} = - IssueHelpers.update_description(issue(), "New body") - end) =~ "CRY-1 description updated" - end - - test "propagates an API error without printing confirmation" do - stub_responses([{"issueUpdate", %{"errors" => [%{"message" => "boom"}]}}]) - - assert capture_io(fn -> - assert {:error, %Ash.Error.Invalid{}} = - IssueHelpers.update_description(issue(), "New body") - end) == "" - end - end - - describe "move_issue/2" do - test "moves the issue to the resolved project and prints a confirmation" do - stub_responses([{"issueUpdate", issue_updated()}]) - - project = %Project{id: "p1", name: "Manhattan Rollout"} - - assert capture_io(fn -> - assert {:ok, %Issue{}} = IssueHelpers.move_issue(issue(), project) - end) =~ "CRY-1 was moved to Manhattan Rollout" - end - - test "propagates an API error without printing confirmation" do - stub_responses([{"issueUpdate", %{"errors" => [%{"message" => "boom"}]}}]) - - project = %Project{id: "p1", name: "Manhattan Rollout"} - - assert capture_io(fn -> - assert {:error, %Ash.Error.Invalid{}} = IssueHelpers.move_issue(issue(), project) - end) == "" - end - end - - describe "attach_project/2 (Ruby: CLI::Issue#attach_project)" do - test "resolves the project by name against the team's projects and attaches it" do - stub_responses([ - {"projects(first: 100", - team_projects([ - %{ - "id" => "p1", - "name" => "Manhattan Rollout", - "content" => nil, - "slugId" => "abc", - "description" => nil, - "url" => "https://linear.app/x/project/manhattan-rollout-abc" - } - ])}, - {"issueUpdate", issue_updated()} - ]) - - assert capture_io(fn -> - assert {:ok, %Issue{}} = - IssueHelpers.attach_project(issue(), "Manhattan Rollout") - end) =~ "CRY-1 was moved to Manhattan Rollout" - end - end - - describe "update_issue/2 dispatch (Ruby: CLI::Issue#update_issue)" do - test "with :close, comments then closes" do - stub_responses([ - {"commentCreate", comment_created()}, - {"states {", - workflow_states([ - %{"id" => "s1", "name" => "Done", "position" => 1.0, "type" => "completed"} - ])}, - {"issueUpdate", issue_updated()} - ]) - - output = - capture_io(fn -> - assert :ok = IssueHelpers.update_issue(issue(), close: true, reason: "done") - end) - - assert output =~ "CRY-1 was closed" - end - - test "with :cancel, comments then cancels" do - stub_responses([ - {"commentCreate", comment_created()}, - {"states {", - workflow_states([ - %{"id" => "s1", "name" => "Cancelled", "position" => 1.0, "type" => "cancelled"} - ])}, - {"issueUpdate", issue_updated()} - ]) - - output = - capture_io(fn -> - assert :ok = IssueHelpers.update_issue(issue(), cancel: true, reason: "nope") - end) - - assert output =~ "CRY-1 was cancelled" - end - - test "with :pr, opens a PR via the injectable runner and never calls the API" do - output = - capture_io(fn -> - assert :ok = - IssueHelpers.update_issue(issue(), - pr: true, - title: "fix: CRY-1 - Fix the thing", - description: "body", - runner: fn _title, _body -> "https://github.com/x/y/pull/1" end - ) - end) - - assert output =~ "https://github.com/x/y/pull/1" - end - - test "with :project, resolves and attaches" do - stub_responses([ - {"projects(first: 100", - team_projects([ - %{ - "id" => "p1", - "name" => "Manhattan Rollout", - "content" => nil, - "slugId" => "abc", - "description" => nil, - "url" => "https://linear.app/x/project/manhattan-rollout-abc" - } - ])}, - {"issueUpdate", issue_updated()} - ]) - - output = - capture_io(fn -> - assert :ok = IssueHelpers.update_issue(issue(), project: "Manhattan Rollout") - end) - - assert output =~ "CRY-1 was moved to Manhattan Rollout" - end - - test "with :description, updates the issue description" do - stub_responses([{"issueUpdate", issue_updated(%{"description" => "New body"})}]) - - output = - capture_io(fn -> - assert :ok = IssueHelpers.update_issue(issue(), description: "New body") - end) - - assert output =~ "CRY-1 description updated" - end - - test "with only :comment, comments and stops without the 'no action taken' warning" do - stub_responses([{"commentCreate", comment_created()}]) - - output = - capture_io(fn -> - assert :ok = IssueHelpers.update_issue(issue(), comment: "fyi") - end) - - assert output =~ "Comment added to CRY-1" - refute output =~ "No action taken" - end - - test "with no options at all, warns and reports no update, without calling the API" do - output = - capture_io(fn -> - assert :ok = IssueHelpers.update_issue(issue()) - end) - - assert output =~ "No action taken, no options specified" - assert output =~ "Issue was not updated" - end - - test "an error from a dispatched action propagates as {:error, reason}" do - stub_responses([ - {"commentCreate", comment_created()}, - {"states {", errors("boom")} - ]) - - assert capture_io(fn -> - assert {:error, %Ash.Error.Unknown{}} = - IssueHelpers.update_issue(issue(), close: true, reason: "x") - end) =~ "Comment added to CRY-1" - end - end - describe "make_da_issue!/1 (Ruby: CLI::Issue#make_da_issue!)" do test "creates the issue with resolved title/description/team/labels/project" do stub_responses([