From d381cb32d1be767aeb2ad3f7dbd10f5286924055 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 11 Aug 2026 14:40:08 -0400 Subject: [PATCH] feat(cli): resolve bare issue numbers via active profile, favorited teams, or a team prompt (#59) closes #59 release-as: 1.0.0 --- Readme.adoc | 5 + app/lib/linear_cli/cli/commands.ex | 5 +- app/lib/linear_cli/cli/issue_helpers.ex | 64 ++++- .../linear_cli/cli/expand_issue_id_test.exs | 92 +++++++ .../linear_cli/cli/profile_defaults_test.exs | 250 ++++++++++++++++++ 5 files changed, 406 insertions(+), 10 deletions(-) create mode 100644 app/test/linear_cli/cli/expand_issue_id_test.exs diff --git a/Readme.adoc b/Readme.adoc index 0012f41..8d94d67 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -262,6 +262,11 @@ $ lc profile delete manhattan An explicit `--team`/`--project` on the command line always overrides the active profile. +Issue commands also accept a bare number (`lc issue develop 1234`) in +place of a full team-prefixed identifier - it's resolved via the active +profile's team, then favorited teams, then a prompt across every team you +belong to. + ==== Favorite teams/projects Not in Ruby's `linear-cli` - favorite the teams/projects you actually diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 8b2d7a0..cb30893 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -230,7 +230,7 @@ defmodule LinearCli.CLI.Commands do with {:ok, project_id} <- resolve_project_id(options.project || Profiles.default_project()) do input = %{ - ids: ids, + ids: Enum.map(ids, &IssueHelpers.expand_issue_id/1), mine: !flags.no_mine, unassigned: flags.unassigned, team_key: team_key, @@ -440,7 +440,8 @@ defmodule LinearCli.CLI.Commands do @spec issue_update(Optimus.ParseResult.t()) :: :ok | {:error, term()} def issue_update(%{unknown: issue_ids, options: options, flags: flags}) do with :ok <- validate_issue_ids(issue_ids), - {:ok, issues} <- Linear.issues(%{ids: issue_ids}) do + {:ok, issues} <- + Linear.issues(%{ids: Enum.map(issue_ids, &IssueHelpers.expand_issue_id/1)}) do update_opts = [ comment: options.comment, project: options.project, diff --git a/app/lib/linear_cli/cli/issue_helpers.ex b/app/lib/linear_cli/cli/issue_helpers.ex index 4935b14..3e6ca8b 100644 --- a/app/lib/linear_cli/cli/issue_helpers.ex +++ b/app/lib/linear_cli/cli/issue_helpers.ex @@ -78,7 +78,13 @@ defmodule LinearCli.CLI.IssueHelpers do """ alias LinearCli.CLI.{Projects, Prompt, WhatFor} - alias LinearCli.{Linear, Profiles} + 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+$/ @doc """ Adds a comment to `issue`, resolving `comment` (asking, or opening an @@ -368,6 +374,42 @@ 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. @@ -381,18 +423,24 @@ 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) + with {:ok, me} <- resolve_me(opts), {:ok, [issue]} <- Linear.issues(%{ids: [issue_id]}) do - if issue.assignee && issue.assignee.id == me.id do - Prompt.say("You are already assigned #{issue_id}") - {:ok, issue} - else - Prompt.say("Assigning issue #{issue_id} to ya") - Linear.assign_issue(issue, me.id) - end + assign_or_confirm(issue, me, issue_id) end end + defp assign_or_confirm(%{assignee: %{id: id}} = issue, %{id: id}, issue_id) do + Prompt.say("You are already assigned #{issue_id}") + {:ok, issue} + end + + defp assign_or_confirm(issue, me, issue_id) do + Prompt.say("Assigning issue #{issue_id} to ya") + Linear.assign_issue(issue, me.id) + end + defp resolve_me(opts) do case Keyword.fetch(opts, :me) do {:ok, me} -> {:ok, me} diff --git a/app/test/linear_cli/cli/expand_issue_id_test.exs b/app/test/linear_cli/cli/expand_issue_id_test.exs new file mode 100644 index 0000000..f006977 --- /dev/null +++ b/app/test/linear_cli/cli/expand_issue_id_test.exs @@ -0,0 +1,92 @@ +defmodule LinearCli.CLI.ExpandIssueIdTest do + # Not async: shares LinearCli.Profiles/LinearCli.Favorites' one sqlite + # file (config :linear_cli, :profiles_db_path) - see + # LinearCli.CLI.ProfileDefaultsTest's own comment for why this is safe. + use ExUnit.Case, async: false + import ExUnit.CaptureIO + + alias LinearCli.CLI.IssueHelpers + alias LinearCli.{Favorites, Profiles} + + setup do + path = Application.fetch_env!(:linear_cli, :profiles_db_path) + File.rm(path) + :ok + end + + defp teams_response(teams) do + %{ + "data" => %{ + "viewer" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@example.com", + "teams" => %{"nodes" => teams} + } + } + } + end + + describe "expand_issue_id/1 (Phase 11: bare issue numbers)" do + test "a bare number resolves via the active profile's team, without prompting" do + {:ok, _} = Profiles.create("manhattan", team: "CRY") + :ok = Profiles.activate("manhattan") + + assert capture_io(fn -> + assert IssueHelpers.expand_issue_id("1234") == "CRY-1234" + end) == "" + end + + test "with no active profile but one favorited team, uses it directly, without prompting" do + Favorites.add("team", "ENG") + + assert capture_io(fn -> + assert IssueHelpers.expand_issue_id("42") == "ENG-42" + end) == "" + end + + test "with no active profile and several favorited teams, prompts and uses the selected one" do + Favorites.add("team", "ENG") + Favorites.add("team", "SUP") + + output = + capture_io([input: "2\n"], fn -> + assert IssueHelpers.expand_issue_id("42") == "SUP-42" + end) + + assert output =~ "Choose a team" + end + + test "with no profile and no favorites, falls through to ask_for_team/0's own full prompt" do + Req.Test.stub(LinearCli.Api, fn conn -> + Req.Test.json( + conn, + teams_response([ + %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + %{"id" => "t2", "key" => "SUP", "name" => "Support"} + ]) + ) + end) + + output = + capture_io([input: "2\n"], fn -> + assert IssueHelpers.expand_issue_id("42") == "SUP-42" + end) + + assert output =~ "Choose a team" + end + + test "an already-prefixed id and a UUID pass through unchanged, regardless of profile/favorites" do + {:ok, _} = Profiles.create("manhattan", team: "CRY") + :ok = Profiles.activate("manhattan") + Favorites.add("team", "ENG") + + assert capture_io(fn -> + assert IssueHelpers.expand_issue_id("CRY-1234") == "CRY-1234" + + assert IssueHelpers.expand_issue_id("550e8400-e29b-41d4-a716-446655440000") == + "550e8400-e29b-41d4-a716-446655440000" + end) == "" + end + end +end diff --git a/app/test/linear_cli/cli/profile_defaults_test.exs b/app/test/linear_cli/cli/profile_defaults_test.exs index f7f31de..5205f13 100644 --- a/app/test/linear_cli/cli/profile_defaults_test.exs +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -8,6 +8,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do import ExUnit.CaptureIO alias LinearCli.CLI.{Commands, IssueHelpers} + alias LinearCli.Linear.User alias LinearCli.Profiles setup do @@ -90,6 +91,55 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do } end + defp me_map(overrides \\ %{}) do + Map.merge( + %{"id" => "u1", "name" => "Ada", "email" => "ada@x.com", "teams" => %{"nodes" => []}}, + overrides + ) + end + + defp comment_created do + %{ + "data" => %{"commentCreate" => %{"comment" => %{"id" => "c1", "body" => "x", "url" => "u"}}} + } + end + + # Every git-touching test gets a fresh local repo (one commit on "main", + # already pushed to/tracking a fresh bare "origin") under + # `System.tmp_dir!()` - never the real project working directory. See + # `LinearCli.CLI.IssueCommandsTest`'s own identical setup. + defp git_repo! do + origin_path = tmp_path("origin") + File.mkdir_p!(origin_path) + {_output, 0} = System.cmd("git", ["init", "--bare", "-q"], cd: origin_path) + + repo_path = tmp_path("repo") + File.mkdir_p!(repo_path) + {_output, 0} = System.cmd("git", ["init", "-q"], cd: repo_path) + {_output, 0} = System.cmd("git", ["config", "user.name", "Test User"], cd: repo_path) + {_output, 0} = System.cmd("git", ["config", "user.email", "test@example.com"], cd: repo_path) + File.write!(Path.join(repo_path, "README.md"), "hello") + {_output, 0} = System.cmd("git", ["add", "README.md"], cd: repo_path) + {_output, 0} = System.cmd("git", ["commit", "-q", "-m", "init"], cd: repo_path) + {_output, 0} = System.cmd("git", ["branch", "-M", "main"], cd: repo_path) + {_output, 0} = System.cmd("git", ["remote", "add", "origin", origin_path], cd: repo_path) + {_output, 0} = System.cmd("git", ["push", "-q", "-u", "origin", "main"], cd: repo_path) + + on_exit(fn -> + File.rm_rf!(origin_path) + File.rm_rf!(repo_path) + end) + + repo_path + end + + defp tmp_path(prefix) do + Path.join( + System.tmp_dir!(), + "linear_cli_profile_defaults_test_#{prefix}_#{System.unique_integer([:positive, :monotonic])}" + ) + end + describe "Commands.issue_list/1 falls back to the active profile" do test "uses the active profile's team/project when both flags are omitted" do {:ok, _} = Profiles.create("manhattan", team: "CRY", project: "Manhattan Rollout") @@ -165,6 +215,206 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do assert filter["team"] == %{"key" => %{"eq" => "ENG"}} assert filter["project"] == %{"id" => %{"eq" => "p2"}} end + + test "resolves bare issue numbers (positional ids) via the active profile's team" do + {:ok, _} = Profiles.create("manhattan", team: "CRY") + :ok = Profiles.activate("manhattan") + + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issue(id: $id)") -> + send(test_pid, {:id, decoded["variables"]["id"]}) + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{ + flags: %{no_mine: false, unassigned: false, full: false}, + options: %{team: nil, project: nil, output: "text"}, + unknown: ["42"] + } + + output = capture_io(fn -> assert :ok = Commands.issue_list(result) end) + + assert output =~ "CRY-1" + assert_received {:id, "CRY-42"} + end + end + + describe "Commands.issue_update/1 resolves bare issue numbers via the active profile" do + test "expands a bare positional id before looking it up" do + {:ok, _} = Profiles.create("manhattan", team: "CRY") + :ok = Profiles.activate("manhattan") + + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issue(id: $id)") -> + send(test_pid, {:id, decoded["variables"]["id"]}) + Req.Test.json(conn, %{"data" => %{"issue" => issue_map()}}) + + String.contains?(query, "commentCreate") -> + Req.Test.json(conn, comment_created()) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{ + unknown: ["42"], + options: %{comment: "fyi", project: nil, reason: nil}, + flags: %{cancel: false, close: false, trash: false} + } + + output = capture_io(fn -> assert :ok = Commands.issue_update(result) end) + + assert output =~ "Comment added to CRY-1" + assert_received {:id, "CRY-42"} + end + end + + describe "Commands.issue_develop/2, issue_pr/2, issue_take/2 resolve bare issue numbers via the active profile" do + test "issue_develop/2 expands the bare issue_id before self-assigning/checking it out" do + {:ok, _} = Profiles.create("manhattan", team: "CRY") + :ok = Profiles.activate("manhattan") + + repo = git_repo!() + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issue(id: $id)") -> + send(test_pid, {:id, decoded["variables"]["id"]}) + + Req.Test.json( + conn, + %{ + "data" => %{ + "issue" => issue_map(%{"branchName" => "main", "assignee" => me_map()}) + } + } + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{args: %{issue_id: "42"}} + + output = + capture_io(fn -> + assert :ok = Commands.issue_develop(result, cwd: repo, me: me) + end) + + assert output =~ "Checked out branch main" + assert_received {:id, "CRY-42"} + end + + test "issue_pr/2 expands the bare issue_id before self-assigning/checking it out" do + {:ok, _} = Profiles.create("manhattan", team: "CRY") + :ok = Profiles.activate("manhattan") + + repo = git_repo!() + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issue(id: $id)") -> + send(test_pid, {:id, decoded["variables"]["id"]}) + + Req.Test.json( + conn, + %{ + "data" => %{ + "issue" => issue_map(%{"branchName" => "main", "assignee" => me_map()}) + } + } + ) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{args: %{issue_id: "42"}, options: %{title: "fix: title", description: "body"}} + + output = + capture_io(fn -> + assert :ok = + Commands.issue_pr(result, + cwd: repo, + me: me, + runner: fn _title, _body -> "https://github.com/x/y/pull/1" end + ) + end) + + assert output =~ "Checked out branch main" + assert_received {:id, "CRY-42"} + end + + test "issue_take/2 expands every bare id in the batch before self-assigning" do + {:ok, _} = Profiles.create("manhattan", team: "CRY") + :ok = Profiles.activate("manhattan") + + me = %User{id: "u1", name: "Ada", email: "ada@x.com"} + test_pid = self() + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + decoded = Jason.decode!(body) + query = decoded["query"] + + cond do + String.contains?(query, "issue(id: $id)") -> + send(test_pid, {:id, decoded["variables"]["id"]}) + Req.Test.json(conn, %{"data" => %{"issue" => issue_map(%{"assignee" => nil})}}) + + String.contains?(query, "issueUpdate") -> + Req.Test.json(conn, %{ + "data" => %{"issueUpdate" => %{"issue" => issue_map(%{"assignee" => me_map()})}} + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{unknown: ["42"], options: %{output: "text"}} + + output = + capture_io(fn -> + assert :ok = Commands.issue_take(result, me: me) + end) + + assert output =~ "Assigning issue CRY-42 to ya" + assert_received {:id, "CRY-42"} + end end describe "IssueHelpers.make_da_issue!/1 falls back to the active profile" do