Skip to content

Commit b9fb765

Browse files
bougymanclaude
andcommitted
feat(issue): add interactive issue assign command
Add `lc issue assign <issue-id>` to assign an issue to any team member, not just the current user (which `issue take` already handles). - User.Read.ByTeam: new manual read action fetching team members via `team(id:) { members(first: 50) { nodes { ... } } }` GraphQL query - Linear.team_members/1: new domain code interface - Commands.issue_assign/1: resolves issue, fetches team members, resolves target via --assignee flag (exact then prefix match) or Prompt.select/2, then calls Linear.assign_issue/2 and displays the result - Optimus spec for `assign` subcommand with --assignee/-a option - Dispatch clause and "a" alias under issue subcommands - 14 new tests: flag-based, prefix match, ambiguous/unknown errors, interactive selection, JSON output, alias routing, empty-members guard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ddc8ca7 commit b9fb765

5 files changed

Lines changed: 392 additions & 0 deletions

File tree

app/lib/linear_cli/cli.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ defmodule LinearCli.CLI do
9393
# too, so this port doesn't need to invent one.
9494
@subcommand_aliases %{
9595
"issue" => %{
96+
"a" => "assign",
9697
"c" => "create",
9798
"new" => "create",
9899
"add" => "create",
@@ -199,6 +200,7 @@ defmodule LinearCli.CLI do
199200
do: run(&Commands.profile_clear/1, result, halt)
200201

201202
defp dispatch([:issue, :list], result, halt), do: run(&Commands.issue_list/1, result, halt)
203+
defp dispatch([:issue, :assign], result, halt), do: run(&Commands.issue_assign/1, result, halt)
202204
defp dispatch([:issue, :create], result, halt), do: run(&Commands.issue_create/1, result, halt)
203205

204206
defp dispatch([:issue, :develop], result, halt),
@@ -564,6 +566,20 @@ defmodule LinearCli.CLI do
564566
]
565567
]
566568
],
569+
assign: [
570+
name: "assign",
571+
about: "Assign an issue to a team member",
572+
args: [
573+
issue_id: [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: true]
574+
],
575+
options: [
576+
assignee: [
577+
short: "-a",
578+
long: "--assignee",
579+
help: "Team member name to assign to (prompts if omitted)"
580+
]
581+
]
582+
],
567583
create: [
568584
name: "create",
569585
about:

app/lib/linear_cli/cli/commands.ex

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,4 +543,67 @@ defmodule LinearCli.CLI.Commands do
543543
{:error, reason} -> {:error, reason}
544544
end
545545
end
546+
547+
@doc """
548+
Assigns an issue to a team member.
549+
550+
With `--assignee`/`-a`, matches the given name against the issue's team's
551+
members (case-insensitive exact, then unique prefix). Without it, prompts
552+
interactively via `LinearCli.CLI.Prompt.select/2`.
553+
"""
554+
@spec issue_assign(Optimus.ParseResult.t()) :: :ok | {:error, term()}
555+
def issue_assign(%{args: %{issue_id: issue_id}, options: options}) do
556+
expanded_id = IssueHelpers.expand_issue_id(issue_id)
557+
558+
with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}),
559+
{:ok, members} <- Linear.team_members(issue.team.id),
560+
:ok <- guard_has_members(members, issue),
561+
{:ok, target_member} <- resolve_target_member(members, options.assignee),
562+
{:ok, updated} <- Linear.assign_issue(issue, target_member.id) do
563+
Display.show(updated, %{output: options.output})
564+
565+
if options.output != "json",
566+
do: Prompt.ok("#{updated.identifier} assigned to #{target_member.name}")
567+
568+
:ok
569+
end
570+
end
571+
572+
defp guard_has_members([], issue) do
573+
{:error, {:smells_bad, "No assignable members found for team #{issue.team.key || issue.team.id}"}}
574+
end
575+
576+
defp guard_has_members(_members, _issue), do: :ok
577+
578+
defp resolve_target_member(members, nil) do
579+
choices = Enum.sort_by(members, & &1.name) |> Enum.map(&{&1.name, &1})
580+
{:ok, Prompt.select("Choose an assignee", choices)}
581+
end
582+
583+
defp resolve_target_member(members, name) do
584+
normalized = String.downcase(name)
585+
586+
members
587+
|> Enum.filter(&(String.downcase(&1.name) == normalized))
588+
|> use_prefix_member_matches_if_empty(members, normalized)
589+
|> resolve_member_matches(members, name)
590+
end
591+
592+
defp use_prefix_member_matches_if_empty([], members, name) do
593+
Enum.filter(members, &String.starts_with?(String.downcase(&1.name), name))
594+
end
595+
596+
defp use_prefix_member_matches_if_empty(matches, _members, _name), do: matches
597+
598+
defp resolve_member_matches([member], _members, _name), do: {:ok, member}
599+
600+
defp resolve_member_matches([], members, name) do
601+
available = Enum.map_join(Enum.sort_by(members, & &1.name), ", ", & &1.name)
602+
{:error, {:smells_bad, "Unknown assignee #{inspect(name)}. Available: #{available}"}}
603+
end
604+
605+
defp resolve_member_matches(matches, _members, name) do
606+
ambiguous = Enum.map_join(matches, ", ", & &1.name)
607+
{:error, {:smells_bad, "Ambiguous assignee #{inspect(name)}: matches #{ambiguous}"}}
608+
end
546609
end

app/lib/linear_cli/linear.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ defmodule LinearCli.Linear do
1313
resources do
1414
resource LinearCli.Linear.User do
1515
define :me, action: :me, get?: true
16+
define :team_members, action: :by_team, args: [:team_id]
1617
end
1718

1819
resource LinearCli.Linear.Team do

app/lib/linear_cli/linear/user.ex

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ defmodule LinearCli.Linear.User do
1010
get? true
1111
manual LinearCli.Linear.User.Read.Me
1212
end
13+
14+
read :by_team do
15+
argument :team_id, :string, allow_nil?: false
16+
manual LinearCli.Linear.User.Read.ByTeam
17+
end
1318
end
1419

1520
attributes do
@@ -55,3 +60,27 @@ defmodule LinearCli.Linear.User.Read.Me do
5560
end
5661
end
5762
end
63+
64+
defmodule LinearCli.Linear.User.Read.ByTeam do
65+
@moduledoc false
66+
use Ash.Resource.ManualRead
67+
68+
alias LinearCli.Api
69+
alias LinearCli.Linear.User
70+
71+
def read(query, _ecto_query, _opts, _context) do
72+
team_id = query.arguments.team_id
73+
document = "query($id: String!) { team(id: $id) { members(first: 50) { nodes { #{User.base_fields()} } } } }"
74+
75+
case Api.call(document, %{"id" => team_id}) do
76+
{:ok, %{"team" => %{"members" => %{"nodes" => nodes}}}} ->
77+
{:ok, Enum.map(nodes, &User.from_map/1)}
78+
79+
{:ok, %{"team" => nil}} ->
80+
{:ok, []}
81+
82+
{:error, reason} ->
83+
{:error, reason}
84+
end
85+
end
86+
end

0 commit comments

Comments
 (0)