diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 3b8e898..eda877d 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -6,6 +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.{Favorites, Git, Linear, Profiles} @max_concurrent_issue_updates 20 @@ -273,7 +274,7 @@ defmodule LinearCli.CLI.Commands do include_labels = Map.get(flags, :include_labels, false) || label_filter != [] input = %{ - ids: Enum.map(ids, &IssueHelpers.expand_issue_id/1), + ids: Enum.map(ids, &Identifiers.expand_issue_id/1), mine: !flags.no_mine, unassigned: flags.unassigned, team_key: team_key, @@ -310,7 +311,7 @@ defmodule LinearCli.CLI.Commands do def issue_view(result, opts \\ []) def issue_view(%{args: %{issue_id: issue_id}, flags: flags, options: options}, opts) do - expanded_id = IssueHelpers.expand_issue_id(issue_id) + expanded_id = Identifiers.expand_issue_id(issue_id) with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}) do if flags.web do @@ -554,7 +555,7 @@ defmodule LinearCli.CLI.Commands do :ok <- validate_body_file_exclusion(options, :description, "--description"), {:ok, description} <- resolve_body_from_file(options, :description), {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}) do + Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}) do update_opts = [ comment: options.comment, description: description, @@ -603,7 +604,7 @@ defmodule LinearCli.CLI.Commands do :ok <- validate_body_file_exclusion(options, :comment, "--comment"), {:ok, comment_text} <- resolve_body_from_file(options, :comment), {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}), + Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), body = WhatFor.comment_for(hd(issues), comment_text), {:ok, pairs} <- add_comments_to_issues(issues, body) do unless options.output == "json" do @@ -697,7 +698,7 @@ defmodule LinearCli.CLI.Commands do 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)}), + Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.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) @@ -890,7 +891,7 @@ defmodule LinearCli.CLI.Commands do def issue_status(%{unknown: issue_ids, options: options}) do with :ok <- validate_issue_ids(issue_ids), {:ok, issues} <- - Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}), + Linear.issues(%{ids: Enum.map(issue_ids, &Identifiers.expand_issue_id/1)}), {:ok, planned_updates} <- plan_status_updates(issues, options.status), {:ok, completed_updates} <- apply_status_updates(planned_updates, options.comment) do show_status_updates(completed_updates, options.output) @@ -1012,7 +1013,7 @@ defmodule LinearCli.CLI.Commands do """ @spec issue_relation_list(Optimus.ParseResult.t()) :: :ok | {:error, term()} def issue_relation_list(%{args: %{issue_id: issue_id}, options: options}) do - expanded_id = IssueHelpers.expand_issue_id(issue_id) + expanded_id = Identifiers.expand_issue_id(issue_id) with {:ok, relations} <- Linear.issue_relations(expanded_id) do Display.show(relations, %{output: options.output, relations: true}) @@ -1043,12 +1044,12 @@ defmodule LinearCli.CLI.Commands do do: {:error, {:smells_bad, "At least one RELATED_ISSUE is required"}} def issue_relation_add(%{unknown: [subject_id | related_ids], options: options}) do - expanded_subject = IssueHelpers.expand_issue_id(subject_id) + expanded_subject = Identifiers.expand_issue_id(subject_id) user_type = options.type results = Enum.map(related_ids, fn related_id -> - expanded_related = IssueHelpers.expand_issue_id(related_id) + expanded_related = Identifiers.expand_issue_id(related_id) add_single_relation(expanded_subject, expanded_related, user_type) end) @@ -1201,13 +1202,13 @@ defmodule LinearCli.CLI.Commands do do: {:error, {:smells_bad, "At least one RELATED_ISSUE is required"}} def issue_relation_remove(%{unknown: [subject_id | related_ids], options: options}) do - expanded_subject = IssueHelpers.expand_issue_id(subject_id) + expanded_subject = Identifiers.expand_issue_id(subject_id) user_type = options.type with {:ok, all_relations} <- Linear.issue_relations(expanded_subject) do results = Enum.map(related_ids, fn related_id -> - expanded_related = IssueHelpers.expand_issue_id(related_id) + expanded_related = Identifiers.expand_issue_id(related_id) remove_single_relation(expanded_subject, expanded_related, user_type, all_relations) end) @@ -1387,7 +1388,7 @@ defmodule LinearCli.CLI.Commands do """ @spec issue_assign(Optimus.ParseResult.t()) :: :ok | {:error, term()} def issue_assign(%{args: %{issue_id: issue_id}, options: options}) do - expanded_id = IssueHelpers.expand_issue_id(issue_id) + expanded_id = Identifiers.expand_issue_id(issue_id) with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), {:ok, members} <- Linear.team_members(issue.team.id), diff --git a/app/lib/linear_cli/cli/issue/identifiers.ex b/app/lib/linear_cli/cli/issue/identifiers.ex new file mode 100644 index 0000000..b5a025b --- /dev/null +++ b/app/lib/linear_cli/cli/issue/identifiers.ex @@ -0,0 +1,62 @@ +defmodule LinearCli.CLI.Issue.Identifiers do + @moduledoc """ + Bare-issue-ID expansion: turns a plain integer string (e.g. `"1234"`) into + a team-prefixed identifier (`"CRY-1234"`) by resolving a team key. + + Extracted from `LinearCli.CLI.IssueHelpers`. The single public function, + `expand_issue_id/1`, is called by every command that accepts an issue + identifier from the user so that bare numbers work wherever full identifiers + do. + + Team resolution order (never a hard error short of the user having no teams + at all): the active profile's team (`LinearCli.Profiles.default_team/0`) -> + favorited teams (`LinearCli.Favorites.list/1`, single favorite used directly, + several prompted) -> a prompt across every team the user belongs to + (`LinearCli.CLI.WhatFor.ask_for_team/0`). + """ + + alias LinearCli.CLI.{Prompt, WhatFor} + alias LinearCli.{Favorites, Profiles} + + # A "bare" issue id is just digits - anything with a `-` (an already + # team-prefixed identifier, e.g. "CRY-1234") or that otherwise doesn't + # look like an id at all (a UUID) passes through `expand_issue_id/1` + # unchanged. + @bare_issue_id_regex ~r/^\d+$/ + + @doc """ + Expands a bare issue number (`~r/^\\d+$/`, e.g. `"1234"`) to a full + team-prefixed identifier (`"CRY-1234"`) by resolving a team key via + `resolve_bare_team/0`. Anything else (an already-prefixed identifier, a + UUID) is returned unchanged. + + Team resolution order, never a hard error short of the user having no + teams at all: the active profile's team (`LinearCli.Profiles.default_team/0`) + -> favorited teams (`LinearCli.Favorites.list/1`, single favorite used + directly, several prompted) -> a prompt across every team the user + belongs to (`LinearCli.CLI.WhatFor.ask_for_team/0`). + """ + @spec expand_issue_id(String.t()) :: String.t() + def expand_issue_id(issue_id) do + if Regex.match?(@bare_issue_id_regex, issue_id) do + "#{resolve_bare_team()}-#{issue_id}" + else + issue_id + end + end + + defp resolve_bare_team do + case Profiles.default_team() do + nil -> resolve_bare_team_from_favorites() + team_key -> team_key + end + end + + defp resolve_bare_team_from_favorites do + case Favorites.list("team") do + [] -> WhatFor.ask_for_team().key + [team_key] -> team_key + team_keys -> Prompt.select("Choose a team", Enum.map(team_keys, &{&1, &1})) + end + end +end diff --git a/app/lib/linear_cli/cli/issue/workflow_states.ex b/app/lib/linear_cli/cli/issue/workflow_states.ex new file mode 100644 index 0000000..739fb4a --- /dev/null +++ b/app/lib/linear_cli/cli/issue/workflow_states.ex @@ -0,0 +1,132 @@ +defmodule LinearCli.CLI.Issue.WorkflowStates do + @moduledoc """ + Workflow-state selection and status-name matching for issue lifecycle + commands. + + Extracted from `LinearCli.CLI.IssueHelpers`. Provides two entry points for + type-filtered state selection (`cancelled_state_for/2`, + `completed_state_for/2`) and one shared entry point for arbitrary + name/prefix matching (`resolve_workflow_state/2`), which is also called + directly by `LinearCli.CLI.IssueHelpers.gimme_da_issue!/2` to resolve the + `--status` option without duplicating the matching logic. + + ## Return shapes + + All public functions return `{:ok, result} | {:error, term()}`. + `{:error, {:smells_bad, message}}` is returned for user-visible failures + (no matching state, ambiguous prefix, unknown status name) — the same + tagged-tuple convention as `LinearCli.CLI.IssueHelpers`. + + ## State selection / prompt behavior + + - A single matching state is returned directly without prompting. + - Multiple matching states with no `status` argument prompt the user via + `LinearCli.CLI.Prompt.select/2`. + - A `status` argument bypasses the prompt and resolves by exact + case-insensitive name or unique prefix. + """ + + alias LinearCli.CLI.Prompt + alias LinearCli.Linear + + @doc """ + Resolves `issue`'s team's cancelled workflow state. + + When `status` is `nil`, returns the sole cancelled state directly or prompts + among several. When `status` is given, selects by case-insensitive exact name + or unique prefix instead. + + Returns `{:error, {:smells_bad, message}}` if the team has no cancelled-type + workflow state. + + Ported from the combination of Ruby's `BaseModel#cancelled_states` and + `CLI::WhatFor#cancelled_state_for`. + """ + @spec cancelled_state_for(%Linear.Issue{}, String.t() | nil) :: + {:ok, %Linear.WorkflowState{}} | {:error, term()} + def cancelled_state_for(issue, status \\ nil), + do: workflow_state_for(issue, ["cancelled", "canceled"], "cancelled", status) + + @doc """ + Resolves `issue`'s team's completed workflow state. + + When `status` is `nil`, returns the sole completed state directly or prompts + among several. When `status` is given, selects by case-insensitive exact name + or unique prefix instead. + + Returns `{:error, {:smells_bad, message}}` if the team has no completed-type + workflow state. + + Ported from the combination of Ruby's `BaseModel#completed_states` and + `CLI::WhatFor#completed_state_for`. + """ + @spec completed_state_for(%Linear.Issue{}, String.t() | nil) :: + {:ok, %Linear.WorkflowState{}} | {:error, term()} + def completed_state_for(issue, status \\ nil), + do: workflow_state_for(issue, ["completed"], "completed", status) + + @doc """ + Resolves a workflow state from `states` by case-insensitive exact `name` + match, falling back to a unique prefix match if no exact match is found. + + Returns `{:ok, state}` for an unambiguous match, or + `{:error, {:smells_bad, message}}` for zero matches (unknown status) or + multiple prefix matches (ambiguous status). + + Public so that callers outside this module (e.g. + `LinearCli.CLI.IssueHelpers.gimme_da_issue!/2` resolving `--status`) can + use the same matching logic without duplicating it. + """ + @spec resolve_workflow_state([%Linear.WorkflowState{}], String.t()) :: + {:ok, %Linear.WorkflowState{}} | {:error, term()} + def resolve_workflow_state(states, name) do + normalized = String.downcase(name) + + states + |> Enum.filter(&(String.downcase(&1.name) == normalized)) + |> use_prefix_state_matches_if_empty(states, normalized) + |> resolve_workflow_state_matches(states, name) + end + + defp workflow_state_for(issue, types, label, status) do + with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id) do + states + |> Enum.filter(&(&1.type in types)) + |> select_workflow_state(issue, label, status) + end + end + + defp select_workflow_state([], issue, label, _status) do + smells_bad("No #{label} workflow states found for team #{issue.team.key || issue.team.id}") + end + + defp select_workflow_state([state], _issue, _label, nil), do: {:ok, state} + + defp select_workflow_state(states, _issue, label, nil) do + {:ok, Prompt.select("Choose a #{label} state", Enum.map(states, &{&1.name, &1}))} + end + + defp select_workflow_state(states, _issue, _label, status) do + resolve_workflow_state(states, status) + end + + defp use_prefix_state_matches_if_empty([], states, name) do + Enum.filter(states, &String.starts_with?(String.downcase(&1.name), name)) + end + + defp use_prefix_state_matches_if_empty(matches, _states, _name), do: matches + + defp resolve_workflow_state_matches([state], _states, _name), do: {:ok, state} + + defp resolve_workflow_state_matches([], states, name) do + available = Enum.map_join(states, ", ", & &1.name) + smells_bad("Unknown status #{inspect(name)}. Available: #{available}") + end + + defp resolve_workflow_state_matches(matches, _states, name) do + ambiguous = Enum.map_join(matches, ", ", & &1.name) + smells_bad("Ambiguous status #{inspect(name)}: matches #{ambiguous}") + end + + defp smells_bad(message), do: {:error, {:smells_bad, message}} +end diff --git a/app/lib/linear_cli/cli/issue_helpers.ex b/app/lib/linear_cli/cli/issue_helpers.ex index 7ef27b5..03f53dd 100644 --- a/app/lib/linear_cli/cli/issue_helpers.ex +++ b/app/lib/linear_cli/cli/issue_helpers.ex @@ -37,17 +37,12 @@ defmodule LinearCli.CLI.IssueHelpers do and print `message` + `halt.(22)`, mirroring Ruby's `CLI::Caller#call` `rescue SmellsBad` clause (which maps to exit code 22). - ## `cancelled_state_for/1` / `completed_state_for/1` - - Ruby has these on `BaseModel` (`#cancelled_states`/`#completed_states`, - filtering `issue.workflow_states`, itself `team.workflow_states`) plus - `CLI::WhatFor#cancelled_state_for`/`#completed_state_for` (the - first-if-only-one-else-prompt logic `close_issue`/`cancel_issue` call). - Neither made it into this port's `LinearCli.CLI.WhatFor` - (`app/lib/linear_cli/cli/what_for.ex`) or anywhere else yet, so both - layers are combined and added here as public functions - the only - reasonable place, since `close_issue/2`/`cancel_issue/2` (this module) - are their only callers. + ## 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. ## Project lookups @@ -77,14 +72,9 @@ defmodule LinearCli.CLI.IssueHelpers do `LinearCli.Git`'s injectable `cwd:`. """ + alias LinearCli.CLI.Issue.{Identifiers, WorkflowStates} alias LinearCli.CLI.{Projects, Prompt, WhatFor} - alias LinearCli.{Favorites, Linear, Profiles} - - # A "bare" issue id is just digits - anything with a `-` (an already - # team-prefixed identifier, e.g. "CRY-1234") or that otherwise doesn't - # look like an id at all (a UUID) passes through `expand_issue_id/1` - # unchanged. - @bare_issue_id_regex ~r/^\d+$/ + alias LinearCli.{Linear, Profiles} @doc """ Adds a comment to `issue`, resolving `comment` (asking, or opening an @@ -130,7 +120,7 @@ defmodule LinearCli.CLI.IssueHelpers do WhatFor.reason_for(opts[:reason], four: "cancelling #{issue.identifier} - #{issue.title}") with {:ok, _comment} <- issue_comment(issue, reason), - {:ok, cancel_state} <- cancelled_state_for(issue, opts[:status]), + {: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} @@ -176,63 +166,8 @@ defmodule LinearCli.CLI.IssueHelpers do end end - defp state_for(true, issue, status), do: cancelled_state_for(issue, status) - defp state_for(_cancelled, issue, status), do: completed_state_for(issue, status) - - @doc """ - Resolves `issue`'s team's single cancelled workflow state directly, or - prompts (`LinearCli.CLI.Prompt.select/2`) among several. When `status` is - given, selects by case-insensitive exact name or unique prefix instead. - - Ported from the combination of Ruby's `BaseModel#cancelled_states` - (`workflow_states.select { |ws| CANCELLED_STATES.include? ws.type }`) and - `CLI::WhatFor#cancelled_state_for` - see this module's moduledoc for why - both live here. Returns `{:error, {:smells_bad, message}}` if the team has - *no* cancelled-type workflow state - Ruby has no equivalent guard (its own - `states.first` on an empty array is silently `nil`). - """ - @spec cancelled_state_for(%Linear.Issue{}, String.t() | nil) :: - {:ok, %Linear.WorkflowState{}} | {:error, term()} - def cancelled_state_for(issue, status \\ nil), - do: workflow_state_for(issue, ["cancelled", "canceled"], "cancelled", status) - - @doc """ - Resolves `issue`'s team's single completed workflow state directly, or - prompts (`LinearCli.CLI.Prompt.select/2`) among several. When `status` is - given, selects by case-insensitive exact name or unique prefix instead. - - Ported from the combination of Ruby's `BaseModel#completed_states` - (`workflow_states.select { |ws| ws.type == 'completed' }`) and - `CLI::WhatFor#completed_state_for` - see this module's moduledoc. Returns - `{:error, {:smells_bad, message}}` if the team has no completed-type - workflow state. - """ - @spec completed_state_for(%Linear.Issue{}, String.t() | nil) :: - {:ok, %Linear.WorkflowState{}} | {:error, term()} - def completed_state_for(issue, status \\ nil), - do: workflow_state_for(issue, ["completed"], "completed", status) - - defp workflow_state_for(issue, types, label, status) do - with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id) do - states - |> Enum.filter(&(&1.type in types)) - |> select_workflow_state(issue, label, status) - end - end - - defp select_workflow_state([], issue, label, _status) do - smells_bad("No #{label} workflow states found for team #{issue.team.key || issue.team.id}") - end - - defp select_workflow_state([state], _issue, _label, nil), do: {:ok, state} - - defp select_workflow_state(states, _issue, label, nil) do - {:ok, Prompt.select("Choose a #{label} state", Enum.map(states, &{&1.name, &1}))} - end - - defp select_workflow_state(states, _issue, _label, status) do - resolve_workflow_state(states, status) - 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 @@ -492,42 +427,6 @@ defmodule LinearCli.CLI.IssueHelpers do defp maybe_put_project_id(params, nil), do: params defp maybe_put_project_id(params, project), do: Map.put(params, :project_id, project.id) - @doc """ - Expands a bare issue number (`~r/^\\d+$/`, e.g. `"1234"`) to a full - team-prefixed identifier (`"CRY-1234"`) by resolving a team key via - `resolve_bare_team/0`. Anything else (an already-prefixed identifier, a - UUID) is returned unchanged. - - Team resolution order, never a hard error short of the user having no - teams at all: the active profile's team (`LinearCli.Profiles.default_team/0`) - -> favorited teams (`LinearCli.Favorites.list/1`, single favorite used - directly, several prompted) -> a prompt across every team the user - belongs to (`LinearCli.CLI.WhatFor.ask_for_team/0`). - """ - @spec expand_issue_id(String.t()) :: String.t() - def expand_issue_id(issue_id) do - if Regex.match?(@bare_issue_id_regex, issue_id) do - "#{resolve_bare_team()}-#{issue_id}" - else - issue_id - end - end - - defp resolve_bare_team do - case Profiles.default_team() do - nil -> resolve_bare_team_from_favorites() - team_key -> team_key - end - end - - defp resolve_bare_team_from_favorites do - case Favorites.list("team") do - [] -> WhatFor.ask_for_team().key - [team_key] -> team_key - team_keys -> Prompt.select("Choose a team", Enum.map(team_keys, &{&1, &1})) - end - end - @doc """ Looks up `issue_id` and self-assigns it to the caller, unless it's already assigned to them. @@ -541,7 +440,7 @@ defmodule LinearCli.CLI.IssueHelpers do """ @spec gimme_da_issue!(String.t(), keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()} def gimme_da_issue!(issue_id, opts \\ []) do - issue_id = expand_issue_id(issue_id) + issue_id = Identifiers.expand_issue_id(issue_id) status_opt = parse_status_opt(opts) with {:ok, me} <- resolve_me(opts), @@ -563,40 +462,13 @@ defmodule LinearCli.CLI.IssueHelpers do defp resolve_status_for_issue(issue, {:name, name}) do with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id) do - case resolve_workflow_state(states, name) do + case WorkflowStates.resolve_workflow_state(states, name) do {:ok, state} -> {:ok, state.id} error -> error end end end - defp resolve_workflow_state(states, name) do - normalized = String.downcase(name) - - states - |> Enum.filter(&(String.downcase(&1.name) == normalized)) - |> use_prefix_state_matches_if_empty(states, normalized) - |> resolve_workflow_state_matches(states, name) - end - - defp use_prefix_state_matches_if_empty([], states, name) do - Enum.filter(states, &String.starts_with?(String.downcase(&1.name), name)) - end - - defp use_prefix_state_matches_if_empty(matches, _states, _name), do: matches - - defp resolve_workflow_state_matches([state], _states, _name), do: {:ok, state} - - defp resolve_workflow_state_matches([], states, name) do - available = Enum.map_join(states, ", ", & &1.name) - smells_bad("Unknown status #{inspect(name)}. Available: #{available}") - end - - defp resolve_workflow_state_matches(matches, _states, name) do - ambiguous = Enum.map_join(matches, ", ", & &1.name) - smells_bad("Ambiguous status #{inspect(name)}: matches #{ambiguous}") - end - defp assign_or_confirm(%{assignee: %{id: id}} = issue, %{id: id}, issue_id, nil) do Prompt.say("You are already assigned #{issue_id}") {:ok, issue} @@ -613,6 +485,4 @@ defmodule LinearCli.CLI.IssueHelpers do :error -> Linear.me() end end - - defp smells_bad(message), do: {:error, {:smells_bad, message}} end diff --git a/app/test/linear_cli/cli/expand_issue_id_test.exs b/app/test/linear_cli/cli/expand_issue_id_test.exs index f006977..14a9d62 100644 --- a/app/test/linear_cli/cli/expand_issue_id_test.exs +++ b/app/test/linear_cli/cli/expand_issue_id_test.exs @@ -5,7 +5,7 @@ defmodule LinearCli.CLI.ExpandIssueIdTest do use ExUnit.Case, async: false import ExUnit.CaptureIO - alias LinearCli.CLI.IssueHelpers + alias LinearCli.CLI.Issue.Identifiers alias LinearCli.{Favorites, Profiles} setup do @@ -33,7 +33,7 @@ defmodule LinearCli.CLI.ExpandIssueIdTest do :ok = Profiles.activate("manhattan") assert capture_io(fn -> - assert IssueHelpers.expand_issue_id("1234") == "CRY-1234" + assert Identifiers.expand_issue_id("1234") == "CRY-1234" end) == "" end @@ -41,7 +41,7 @@ defmodule LinearCli.CLI.ExpandIssueIdTest do Favorites.add("team", "ENG") assert capture_io(fn -> - assert IssueHelpers.expand_issue_id("42") == "ENG-42" + assert Identifiers.expand_issue_id("42") == "ENG-42" end) == "" end @@ -51,7 +51,7 @@ defmodule LinearCli.CLI.ExpandIssueIdTest do output = capture_io([input: "2\n"], fn -> - assert IssueHelpers.expand_issue_id("42") == "SUP-42" + assert Identifiers.expand_issue_id("42") == "SUP-42" end) assert output =~ "Choose a team" @@ -70,7 +70,7 @@ defmodule LinearCli.CLI.ExpandIssueIdTest do output = capture_io([input: "2\n"], fn -> - assert IssueHelpers.expand_issue_id("42") == "SUP-42" + assert Identifiers.expand_issue_id("42") == "SUP-42" end) assert output =~ "Choose a team" @@ -82,9 +82,9 @@ defmodule LinearCli.CLI.ExpandIssueIdTest do Favorites.add("team", "ENG") assert capture_io(fn -> - assert IssueHelpers.expand_issue_id("CRY-1234") == "CRY-1234" + assert Identifiers.expand_issue_id("CRY-1234") == "CRY-1234" - assert IssueHelpers.expand_issue_id("550e8400-e29b-41d4-a716-446655440000") == + assert Identifiers.expand_issue_id("550e8400-e29b-41d4-a716-446655440000") == "550e8400-e29b-41d4-a716-446655440000" end) == "" end diff --git a/app/test/linear_cli/cli/issue/workflow_states_test.exs b/app/test/linear_cli/cli/issue/workflow_states_test.exs new file mode 100644 index 0000000..c8b6f8d --- /dev/null +++ b/app/test/linear_cli/cli/issue/workflow_states_test.exs @@ -0,0 +1,166 @@ +defmodule LinearCli.CLI.Issue.WorkflowStatesTest do + use ExUnit.Case, async: true + import ExUnit.CaptureIO + + alias LinearCli.CLI.Issue.WorkflowStates + alias LinearCli.Linear.{Issue, 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_states(states) do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json(conn, %{"data" => %{"team" => %{"states" => %{"nodes" => states}}}}) + end) + end + + defp state(id, name, type) do + %{"id" => id, "name" => name, "position" => 1.0, "type" => type} + end + + describe "cancelled_state_for/2" do + test "returns the sole cancelled state directly, no prompt" do + stub_states([ + state("s1", "Cancelled", "cancelled"), + state("s2", "Done", "completed") + ]) + + assert capture_io(fn -> + assert {:ok, %WorkflowState{id: "s1"}} = + WorkflowStates.cancelled_state_for(issue()) + end) == "" + end + + test "also matches the American 'canceled' spelling" do + stub_states([state("s1", "Canceled", "canceled")]) + + assert {:ok, %WorkflowState{id: "s1"}} = WorkflowStates.cancelled_state_for(issue()) + end + + test "prompts to disambiguate when several cancelled states exist and status is nil" do + stub_states([ + state("s1", "Cancelled", "cancelled"), + state("s2", "Voided", "cancelled") + ]) + + output = + capture_io([input: "2\n"], fn -> + assert {:ok, %WorkflowState{id: "s2"}} = WorkflowStates.cancelled_state_for(issue()) + end) + + assert output =~ "Choose a cancelled state" + end + + test "selects by status name instead of prompting when status is given" do + stub_states([ + state("s1", "Cancelled", "cancelled"), + state("s2", "Voided", "cancelled") + ]) + + assert capture_io(fn -> + assert {:ok, %WorkflowState{id: "s2"}} = + WorkflowStates.cancelled_state_for(issue(), "Voided") + end) == "" + end + + test "returns a smells_bad error when the team has no cancelled-type state" do + stub_states([state("s1", "Backlog", "backlog")]) + + assert {:error, {:smells_bad, message}} = WorkflowStates.cancelled_state_for(issue()) + assert message =~ "No cancelled workflow states found for team ENG" + end + end + + describe "completed_state_for/2" do + test "returns the sole completed state directly, no prompt" do + stub_states([ + state("s1", "Cancelled", "cancelled"), + state("s2", "Done", "completed") + ]) + + assert capture_io(fn -> + assert {:ok, %WorkflowState{id: "s2"}} = + WorkflowStates.completed_state_for(issue()) + end) == "" + end + + test "prompts to disambiguate when several completed states exist and status is nil" do + stub_states([ + state("s1", "Done", "completed"), + state("s2", "Shipped", "completed") + ]) + + output = + capture_io([input: "2\n"], fn -> + assert {:ok, %WorkflowState{id: "s2"}} = WorkflowStates.completed_state_for(issue()) + end) + + assert output =~ "Choose a completed state" + end + + test "returns a smells_bad error when the team has no completed-type state" do + stub_states([state("s1", "Backlog", "backlog")]) + + assert {:error, {:smells_bad, message}} = WorkflowStates.completed_state_for(issue()) + assert message =~ "No completed workflow states found for team ENG" + end + end + + describe "resolve_workflow_state/2" do + defp states do + [ + %WorkflowState{id: "s1", name: "Todo", type: "unstarted"}, + %WorkflowState{id: "s2", name: "In Progress", type: "started"}, + %WorkflowState{id: "s3", name: "Done", type: "completed"} + ] + end + + test "exact match (case-insensitive) returns the state" do + assert {:ok, %WorkflowState{id: "s2"}} = + WorkflowStates.resolve_workflow_state(states(), "in progress") + end + + test "exact match is case-insensitive" do + assert {:ok, %WorkflowState{id: "s1"}} = + WorkflowStates.resolve_workflow_state(states(), "TODO") + end + + test "unique prefix match returns the state when no exact match exists" do + assert {:ok, %WorkflowState{id: "s2"}} = + WorkflowStates.resolve_workflow_state(states(), "In") + end + + test "unknown name returns a smells_bad error listing available states" do + assert {:error, {:smells_bad, message}} = + WorkflowStates.resolve_workflow_state(states(), "NoSuch") + + assert message =~ "Unknown status" + assert message =~ "NoSuch" + assert message =~ "Todo" + end + + test "ambiguous prefix returns a smells_bad error listing the matches" do + ambiguous_states = [ + %WorkflowState{id: "s1", name: "In Progress", type: "started"}, + %WorkflowState{id: "s2", name: "In Review", type: "started"} + ] + + assert {:error, {:smells_bad, message}} = + WorkflowStates.resolve_workflow_state(ambiguous_states, "In") + + assert message =~ "Ambiguous status" + assert message =~ "In Progress" + assert message =~ "In Review" + 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 5181e01..fce2612 100644 --- a/app/test/linear_cli/cli/issue_helpers_test.exs +++ b/app/test/linear_cli/cli/issue_helpers_test.exs @@ -93,63 +93,6 @@ defmodule LinearCli.CLI.IssueHelpersTest do end end - describe "cancelled_state_for/1 and completed_state_for/1" do - test "returns the sole matching state directly, no prompt" do - stub_responses([ - {"states {", - workflow_states([ - %{"id" => "s1", "name" => "Cancelled", "position" => 1.0, "type" => "cancelled"}, - %{"id" => "s2", "name" => "Done", "position" => 2.0, "type" => "completed"} - ])} - ]) - - assert capture_io(fn -> - assert {:ok, %WorkflowState{id: "s1"}} = - IssueHelpers.cancelled_state_for(issue()) - end) == "" - end - - test "also matches the American 'canceled' spelling" do - stub_responses([ - {"states {", - workflow_states([ - %{"id" => "s1", "name" => "Canceled", "position" => 1.0, "type" => "canceled"} - ])} - ]) - - assert {:ok, %WorkflowState{id: "s1"}} = IssueHelpers.cancelled_state_for(issue()) - end - - test "prompts to disambiguate when several states of the same type exist" do - stub_responses([ - {"states {", - workflow_states([ - %{"id" => "s1", "name" => "Done", "position" => 1.0, "type" => "completed"}, - %{"id" => "s2", "name" => "Shipped", "position" => 2.0, "type" => "completed"} - ])} - ]) - - output = - capture_io([input: "2\n"], fn -> - assert {:ok, %WorkflowState{id: "s2"}} = IssueHelpers.completed_state_for(issue()) - end) - - assert output =~ "Choose a completed state" - end - - test "returns a smells_bad error when the team has no state of that type" do - stub_responses([ - {"states {", - workflow_states([ - %{"id" => "s1", "name" => "Backlog", "position" => 1.0, "type" => "backlog"} - ])} - ]) - - assert {:error, {:smells_bad, message}} = IssueHelpers.cancelled_state_for(issue()) - assert message =~ "No cancelled workflow states found for team ENG" - 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([