diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index d2dc190..e86d21c 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -93,6 +93,7 @@ defmodule LinearCli.CLI do # too, so this port doesn't need to invent one. @subcommand_aliases %{ "issue" => %{ + "a" => "assign", "c" => "create", "new" => "create", "add" => "create", @@ -199,6 +200,7 @@ defmodule LinearCli.CLI do do: run(&Commands.profile_clear/1, result, halt) defp dispatch([:issue, :list], result, halt), do: run(&Commands.issue_list/1, result, halt) + defp dispatch([:issue, :assign], result, halt), do: run(&Commands.issue_assign/1, result, halt) defp dispatch([:issue, :create], result, halt), do: run(&Commands.issue_create/1, result, halt) defp dispatch([:issue, :develop], result, halt), @@ -564,6 +566,20 @@ defmodule LinearCli.CLI do ] ] ], + assign: [ + name: "assign", + about: "Assign an issue to a team member", + args: [ + issue_id: [value_name: "ISSUE_ID", help: "The Issue (i.e. CRY-1)", required: true] + ], + options: [ + assignee: [ + short: "-a", + long: "--assignee", + help: "Team member name to assign to (prompts if omitted)" + ] + ] + ], create: [ name: "create", about: diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 760f016..2fc4270 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -543,4 +543,68 @@ defmodule LinearCli.CLI.Commands do {:error, reason} -> {:error, reason} end end + + @doc """ + Assigns an issue to a team member. + + With `--assignee`/`-a`, matches the given name against the issue's team's + members (case-insensitive exact, then unique prefix). Without it, prompts + interactively via `LinearCli.CLI.Prompt.select/2`. + """ + @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) + + with {:ok, [issue]} <- Linear.issues(%{ids: [expanded_id]}), + {:ok, members} <- Linear.team_members(issue.team.id), + :ok <- guard_has_members(members, issue), + {:ok, target_member} <- resolve_target_member(members, options.assignee), + {:ok, updated} <- Linear.assign_issue(issue, target_member.id) do + Display.show(updated, %{output: options.output}) + + if options.output != "json", + do: Prompt.ok("#{updated.identifier} assigned to #{target_member.name}") + + :ok + end + end + + defp guard_has_members([], issue) do + {:error, + {:smells_bad, "No assignable members found for team #{issue.team.key || issue.team.id}"}} + end + + defp guard_has_members(_members, _issue), do: :ok + + defp resolve_target_member(members, nil) do + choices = Enum.sort_by(members, & &1.name) |> Enum.map(&{&1.name, &1}) + {:ok, Prompt.select("Choose an assignee", choices)} + end + + defp resolve_target_member(members, name) do + normalized = String.downcase(name) + + members + |> Enum.filter(&(String.downcase(&1.name) == normalized)) + |> use_prefix_member_matches_if_empty(members, normalized) + |> resolve_member_matches(members, name) + end + + defp use_prefix_member_matches_if_empty([], members, name) do + Enum.filter(members, &String.starts_with?(String.downcase(&1.name), name)) + end + + defp use_prefix_member_matches_if_empty(matches, _members, _name), do: matches + + defp resolve_member_matches([member], _members, _name), do: {:ok, member} + + defp resolve_member_matches([], members, name) do + available = Enum.map_join(Enum.sort_by(members, & &1.name), ", ", & &1.name) + {:error, {:smells_bad, "Unknown assignee #{inspect(name)}. Available: #{available}"}} + end + + defp resolve_member_matches(matches, _members, name) do + ambiguous = Enum.map_join(matches, ", ", & &1.name) + {:error, {:smells_bad, "Ambiguous assignee #{inspect(name)}: matches #{ambiguous}"}} + end end diff --git a/app/lib/linear_cli/linear.ex b/app/lib/linear_cli/linear.ex index ded07c0..ce88583 100644 --- a/app/lib/linear_cli/linear.ex +++ b/app/lib/linear_cli/linear.ex @@ -13,6 +13,7 @@ defmodule LinearCli.Linear do resources do resource LinearCli.Linear.User do define :me, action: :me, get?: true + define :team_members, action: :by_team, args: [:team_id] end resource LinearCli.Linear.Team do diff --git a/app/lib/linear_cli/linear/user.ex b/app/lib/linear_cli/linear/user.ex index 221c2aa..94ffbdb 100644 --- a/app/lib/linear_cli/linear/user.ex +++ b/app/lib/linear_cli/linear/user.ex @@ -10,6 +10,11 @@ defmodule LinearCli.Linear.User do get? true manual LinearCli.Linear.User.Read.Me end + + read :by_team do + argument :team_id, :string, allow_nil?: false + manual LinearCli.Linear.User.Read.ByTeam + end end attributes do @@ -55,3 +60,26 @@ defmodule LinearCli.Linear.User.Read.Me do end end end + +defmodule LinearCli.Linear.User.Read.ByTeam do + @moduledoc false + use Ash.Resource.ManualRead + + alias LinearCli.Api + alias LinearCli.Linear.User + + def read(query, _ecto_query, _opts, _context) do + team_id = query.arguments.team_id + + document = + "query($id: String!) { team(id: $id) { members(first: 50) { nodes { #{User.base_fields()} } } } }" + + with {:ok, %{"team" => team}} when is_map(team) <- Api.call(document, %{"id" => team_id}), + %{"members" => %{"nodes" => nodes}} <- team do + {:ok, Enum.map(nodes, &User.from_map/1)} + else + {:ok, _} -> {:ok, []} + error -> error + end + end +end diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index c10bc9c..fc7d5ad 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -984,4 +984,287 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert output =~ "This smells bad! Bailing." end end + + describe "issue assign" do + defp member_map(id, name, email \\ nil) do + %{"id" => id, "name" => name, "email" => email || "#{id}@example.com"} + end + + defp members_response(members) do + %{"data" => %{"team" => %{"members" => %{"nodes" => members}}}} + end + + defp issue_assigned(assignee_map) do + %{"data" => %{"issueUpdate" => %{"issue" => issue_map(%{"assignee" => assignee_map})}}} + end + + test "--assignee sets the assignee by exact name (case-insensitive)" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + decoded = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json( + conn, + members_response([member_map("u2", "Bob"), member_map("u3", "Alice")]) + ) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:assignee_id, decoded["variables"]["input"]["assigneeId"]}) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "assign", "--assignee", "bob", "CRY-1"]) + end) + + assert_received {:assignee_id, "u2"} + assert output =~ "assigned to Bob" + end + + test "--assignee prefix match selects unique match" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + decoded = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json( + conn, + members_response([member_map("u2", "Bob"), member_map("u3", "Alice")]) + ) + + String.contains?(query, "issueUpdate") -> + send(test_pid, {:assignee_id, decoded["variables"]["input"]["assigneeId"]}) + Req.Test.json(conn, issue_assigned(member_map("u3", "Alice"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "assign", "--assignee", "Ali", "CRY-1"]) + end) + + assert_received {:assignee_id, "u3"} + assert output =~ "assigned to Alice" + end + + test "--assignee with unknown name exits 22 (smells bad)" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json( + conn, + members_response([member_map("u2", "Bob"), member_map("u3", "Alice")]) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "assign", "--assignee", "Nobody", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "Unknown assignee" + assert stderr =~ "This smells bad! Bailing." + end + + test "--assignee with ambiguous prefix exits 22 (smells bad)" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json( + conn, + members_response([member_map("u2", "Bob"), member_map("u3", "Bobby")]) + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "assign", "--assignee", "Bo", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "Ambiguous assignee" + assert stderr =~ "This smells bad! Bailing." + end + + test "interactive selection (no --assignee) prompts from sorted members" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json( + conn, + members_response([member_map("u2", "Bob"), member_map("u3", "Alice")]) + ) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, issue_assigned(member_map("u3", "Alice"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + # Members are sorted by name: Alice (1), Bob (2) — select "1\n" for Alice + output = + capture_io([input: "1\n"], fn -> + assert :ok = LinearCli.CLI.main(["issue", "assign", "CRY-1"]) + end) + + assert output =~ "Choose an assignee" + assert output =~ "assigned to Alice" + end + + test "--output json emits structured output" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([member_map("u2", "Bob")])) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "assign", + "--assignee", + "Bob", + "--output", + "json", + "CRY-1" + ]) + end) + + assert {:ok, decoded} = Jason.decode(output) + assert decoded["identifier"] == "CRY-1" + end + + test "alias 'a' routes to issue assign" do + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([member_map("u2", "Bob")])) + + String.contains?(query, "issueUpdate") -> + send(test_pid, :assigned) + Req.Test.json(conn, issue_assigned(member_map("u2", "Bob"))) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + assert :ok = LinearCli.CLI.main(["issue", "a", "--assignee", "Bob", "CRY-1"]) + end) + + assert_received :assigned + end + + test "no assignable members exits 22 (smells bad)" do + test_pid = self() + halt = fn code -> send(test_pid, {:halted, code}) end + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + + cond do + String.contains?(query, "issue(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "members(first: 50)") -> + Req.Test.json(conn, members_response([])) + + true -> + raise "no stub matched query: #{query}" + end + end) + + stderr = + capture_io(:stderr, fn -> + LinearCli.CLI.main(["issue", "assign", "CRY-1"], halt) + end) + + assert_received {:halted, 22} + assert stderr =~ "No assignable members" + assert stderr =~ "This smells bad! Bailing." + end + end end