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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions app/lib/linear_cli/cli/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ defmodule LinearCli.CLI.Commands do

alias LinearCli.Browser
alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt, WhatFor}
alias LinearCli.CLI.Issue.{Actions, Identifiers}
alias LinearCli.CLI.Issue.{Actions, Assignment, Creation, Identifiers}
alias LinearCli.{Favorites, Git, Linear, Profiles}

@max_concurrent_issue_updates 20
Expand Down Expand Up @@ -346,7 +346,7 @@ defmodule LinearCli.CLI.Commands do

@doc """
Ported from commands/issue/create.rb: resolves every field
(`LinearCli.CLI.IssueHelpers.make_da_issue!/1`), optionally self-assigns it
(`LinearCli.CLI.Issue.Creation.make_da_issue!/1`), optionally self-assigns it
(`prompt.yes?('Do you want to take this issue?')`, unless `--no-take` was
given), displays it, then, if `--dev` was given, chains straight into the
same flow as `issue_develop/2`
Expand All @@ -355,8 +355,8 @@ defmodule LinearCli.CLI.Commands do

`opts` isn't part of Ruby's `call(**options)` arity - it exists purely to
inject test doubles into whatever this command chains into: `:me`
(`gimme_da_issue!/2`, both for the self-assign prompt and, if `--dev`
fires, `run_develop/2`'s own re-fetch), `:cwd`
(`Assignment.gimme_da_issue!/2`, both for the self-assign prompt and, if
`--dev` fires, `run_develop/2`'s own re-fetch), `:cwd`
(`LinearCli.Git.checkout_branch/2`/`pull_or_push_new_branch!/2`, only
reached with `--dev`). Real callers (`LinearCli.CLI.main/2`) omit it.
"""
Expand All @@ -375,7 +375,7 @@ defmodule LinearCli.CLI.Commands do
project: options.project,
yes: flags.yes
],
{:ok, issue} <- IssueHelpers.make_da_issue!(create_opts),
{:ok, issue} <- Creation.make_da_issue!(create_opts),
:ok <- maybe_take(issue, flags, opts) do
Display.show(issue, %{output: options.output})
if flags.develop, do: run_develop(issue.id, opts), else: :ok
Expand All @@ -390,15 +390,15 @@ defmodule LinearCli.CLI.Commands do
defp maybe_take(_issue, %{no_take: true}, _opts), do: :ok

defp maybe_take(issue, %{yes: true}, opts) do
case IssueHelpers.gimme_da_issue!(issue.id, opts) do
case Assignment.gimme_da_issue!(issue.id, opts) do
{:ok, _updated} -> :ok
{:error, reason} -> {:error, reason}
end
end

defp maybe_take(issue, _flags, opts) do
if Prompt.yes?("Do you want to take this issue?") do
case IssueHelpers.gimme_da_issue!(issue.id, opts) do
case Assignment.gimme_da_issue!(issue.id, opts) do
{:ok, _updated} -> :ok
{:error, reason} -> {:error, reason}
end
Expand All @@ -409,24 +409,24 @@ defmodule LinearCli.CLI.Commands do

@doc """
Ported from commands/issue/develop.rb: resolves/self-assigns `issue_id`
(`LinearCli.CLI.IssueHelpers.gimme_da_issue!/2`), checks out its
(`LinearCli.CLI.Issue.Assignment.gimme_da_issue!/2`), checks out its
`branch_name` (creating it first if it doesn't exist locally yet), then
pulls it (or, if there's no upstream tracking branch yet, pushes it to
`origin` and sets one up).

`opts` (this port's addition, not part of Ruby's `call(issue_id:,
**options)`) forwards to `LinearCli.Git.checkout_branch/2`/
`pull_or_push_new_branch!/2` (`:cwd`) and
`LinearCli.CLI.IssueHelpers.gimme_da_issue!/2` (`:me`) - pass overrides in
tests so this never shells out to real git or hits a real `viewer` query;
`LinearCli.CLI.Issue.Assignment.gimme_da_issue!/2` (`:me`) - pass overrides
in tests so this never shells out to real git or hits a real `viewer` query;
real callers omit it.
"""
@spec issue_develop(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()}
def issue_develop(result, opts \\ [])
def issue_develop(%{args: %{issue_id: issue_id}}, opts), do: run_develop(issue_id, opts)

defp run_develop(issue_id, opts) do
with {:ok, issue} <- IssueHelpers.gimme_da_issue!(issue_id, opts),
with {:ok, issue} <- Assignment.gimme_da_issue!(issue_id, opts),
{:ok, _branch} <- Git.checkout_branch(issue.branch_name, opts) do
Prompt.ok("Checked out branch #{issue.branch_name}")
finish_pull_or_push(issue.branch_name, opts)
Expand Down Expand Up @@ -469,7 +469,7 @@ defmodule LinearCli.CLI.Commands do
def issue_pr(result, opts \\ [])

def issue_pr(%{args: %{issue_id: issue_id}, options: options}, opts) do
with {:ok, issue} <- IssueHelpers.gimme_da_issue!(issue_id, opts),
with {:ok, issue} <- Assignment.gimme_da_issue!(issue_id, opts),
{:ok, _branch} <- Git.checkout_branch(issue.branch_name, opts) do
Prompt.ok("Checked out branch #{issue.branch_name}")

Expand All @@ -495,8 +495,8 @@ defmodule LinearCli.CLI.Commands do
its `filter_map`.

`opts` (this port's addition) forwards to
`LinearCli.CLI.IssueHelpers.gimme_da_issue!/2` (`:me`); real callers omit
it.
`LinearCli.CLI.Issue.Assignment.gimme_da_issue!/2` (`:me`); real callers
omit it.
"""
@spec issue_take(Optimus.ParseResult.t(), keyword()) :: :ok | {:error, term()}
def issue_take(result, opts \\ [])
Expand All @@ -516,7 +516,7 @@ defmodule LinearCli.CLI.Commands do
defp take_issues(issue_ids, opts) do
issue_ids
|> Enum.reduce_while({:ok, []}, fn issue_id, {:ok, acc} ->
case IssueHelpers.gimme_da_issue!(issue_id, opts) do
case Assignment.gimme_da_issue!(issue_id, opts) do
{:ok, issue} ->
{:cont, {:ok, [issue | acc]}}

Expand Down
90 changes: 90 additions & 0 deletions app/lib/linear_cli/cli/issue/assignment.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
defmodule LinearCli.CLI.Issue.Assignment do
@moduledoc """
Issue self-assignment with optional workflow-state transition.

Extracted from `LinearCli.CLI.IssueHelpers`. The single public function,
`gimme_da_issue!/2`, looks up an issue by identifier and self-assigns it
to the current user, unless already assigned. Accepts an optional
`--status` option (or an already-resolved `:state_id`) to simultaneously
transition the issue's workflow state.

Reuses `LinearCli.CLI.Issue.Identifiers.expand_issue_id/1` for bare-ID
expansion and `LinearCli.CLI.Issue.WorkflowStates.resolve_workflow_state/2`
for status-name matching, keeping the shared logic in one place.

Ported from `CLI::Issue#gimme_da_issue!`.

## Return convention

Returns `{:ok, issue}` on success or `{:error, reason}` on failure (never
raises). User-visible failures use `{:error, {:smells_bad, message}}`,
matching the convention established by `LinearCli.CLI.IssueHelpers`.
"""

alias LinearCli.CLI.Issue.{Identifiers, WorkflowStates}
alias LinearCli.CLI.Prompt
alias LinearCli.Linear

@doc """
Looks up `issue_id` and self-assigns it to the caller, unless it's already
assigned to them.

`opts[:me]` overrides the caller lookup (this port's stand-in for Ruby's
`me: Rubyists::Linear::User.me` keyword default) - real callers omit it
and get `LinearCli.Linear.me/0`; tests pass it to avoid stubbing the
`viewer` query too.

`opts[:status]` accepts a case-insensitive exact or unique-prefix workflow
state name to transition the issue at the same time as assignment.
`opts[:state_id]` accepts an already-resolved state ID directly.

Ported from `CLI::Issue#gimme_da_issue!`.
"""
@spec gimme_da_issue!(String.t(), keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()}
def gimme_da_issue!(issue_id, opts \\ []) do
issue_id = Identifiers.expand_issue_id(issue_id)
status_opt = parse_status_opt(opts)

with {:ok, me} <- resolve_me(opts),
{:ok, [issue]} <- Linear.issues(%{ids: [issue_id]}),
{:ok, state_id} <- resolve_status_for_issue(issue, status_opt) do
assign_or_confirm(issue, me, issue_id, state_id)
end
end

defp parse_status_opt(opts) do
case Keyword.fetch(opts, :state_id) do
{:ok, id} -> {:resolved, id}
:error -> {:name, Keyword.get(opts, :status)}
end
end

defp resolve_status_for_issue(_issue, {:resolved, id}), do: {:ok, id}
defp resolve_status_for_issue(_issue, {:name, nil}), do: {:ok, nil}

defp resolve_status_for_issue(issue, {:name, name}) do
with {:ok, states} <- Linear.workflow_states_by_team(issue.team.id) do
case WorkflowStates.resolve_workflow_state(states, name) do
{:ok, state} -> {:ok, state.id}
error -> error
end
end
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}
end

defp assign_or_confirm(issue, me, issue_id, state_id) do
Prompt.say("Assigning issue #{issue_id} to ya")
Linear.assign_issue(issue, me.id, %{state_id: state_id})
end

defp resolve_me(opts) do
case Keyword.fetch(opts, :me) do
{:ok, me} -> {:ok, me}
:error -> Linear.me()
end
end
end
118 changes: 118 additions & 0 deletions app/lib/linear_cli/cli/issue/creation.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
defmodule LinearCli.CLI.Issue.Creation do
@moduledoc """
Interactive and non-interactive issue creation.

Extracted from `LinearCli.CLI.IssueHelpers`. The single public function,
`make_da_issue!/1`, creates a new Linear issue by resolving title,
description, team, labels, and project either interactively (when `--yes`
is not given) or strictly from provided options (when `--yes` is set).

Profile defaults (active team/project) are consulted before interactive
prompting when options are omitted.

Ported from `CLI::Issue#make_da_issue!`.

## Return convention

Returns `{:ok, issue}` on success or `{:error, reason}` on failure (never
raises). User-visible failures use `{:error, {:smells_bad, message}}`,
matching the convention established by `LinearCli.CLI.IssueHelpers`.
"""

alias LinearCli.CLI.{Projects, WhatFor}
alias LinearCli.{Linear, Profiles}

@doc """
Creates a new issue, resolving every field that wasn't already given in
`opts` interactively (title, description, team, labels, project - via
`LinearCli.CLI.WhatFor`/`LinearCli.CLI.Projects`).

`opts` (Ruby's `**options`): `:title`, `:description`, `:team`, `:labels`,
`:project`. `:team`/`:project`, if omitted, fall back to
`LinearCli.Profiles.default_team/0`/`default_project/0` (the active
profile, if any) before `WhatFor.team_for/1`/`Projects.project_for/2`'s
own interactive prompting kicks in.

When `opts[:yes]` is truthy, all fields must be supplied (no interactive
prompts); missing required fields return `{:error, {:smells_bad, message}}`.

Ported from `CLI::Issue#make_da_issue!`.
"""
@spec make_da_issue!(keyword()) :: {:ok, %Linear.Issue{}} | {:error, term()}
def make_da_issue!(opts \\ []) do
if opts[:yes], do: make_da_issue_no_prompts!(opts), else: make_da_issue_interactive!(opts)
end

defp make_da_issue_interactive!(opts) do
title = WhatFor.title_for(opts[:title])
description = WhatFor.description_for(opts[:description])
team = WhatFor.team_for(opts[:team] || Profiles.default_team())
labels = WhatFor.labels_for(team, opts[:labels])
project_search = opts[:project] || Profiles.default_project()

with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: project_search}) do
project = Projects.project_for(projects, project_search)
label_ids = Enum.map(labels, & &1.id)
params = maybe_put_project_id(%{label_ids: label_ids}, project)

Linear.create_issue(title, description, team.id, params)
end
end

defp make_da_issue_no_prompts!(opts) do
with {:ok, title} <- require_field(opts[:title], "--title"),
{:ok, description} <- require_field(opts[:description], "--description"),
{:ok, team} <- resolve_team_strict(opts[:team] || Profiles.default_team()) do
labels =
case opts[:labels] do
nil -> []
[] -> []
labels -> WhatFor.labels_for(team, labels)
end

project_search = opts[:project] || Profiles.default_project()

with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: project_search}) do
project =
if project_search,
do: Projects.project_for_strict(projects, project_search),
else: nil

label_ids = Enum.map(labels, & &1.id)
params = maybe_put_project_id(%{label_ids: label_ids}, project)
Linear.create_issue(title, description, team.id, params)
end
end
end

defp require_field(nil, flag),
do: {:error, {:smells_bad, "#{flag} is required with --yes"}}

defp require_field(value, _flag), do: {:ok, value}

defp resolve_team_strict(nil) do
case Linear.my_teams() do
{:ok, [team]} ->
{:ok, team}

{:ok, []} ->
{:error, {:smells_bad, "--team is required (you belong to no teams)"}}

{:ok, _teams} ->
{:error, {:smells_bad, "--team is required when you belong to multiple teams"}}

{:error, reason} ->
{:error, {:smells_bad, "Could not fetch teams: #{inspect(reason)}"}}
end
end

defp resolve_team_strict(key) do
case Linear.find_team(key) do
{:ok, team} -> {:ok, team}
{:error, _reason} -> {:error, {:smells_bad, "--team #{inspect(key)} not found"}}
end
end

defp maybe_put_project_id(params, nil), do: params
defp maybe_put_project_id(params, project), do: Map.put(params, :project_id, project.id)
end
Loading