diff --git a/Readme.adoc b/Readme.adoc index 7fd644d..0012f41 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -262,6 +262,25 @@ $ lc profile delete manhattan An explicit `--team`/`--project` on the command line always overrides the active profile. +==== Favorite teams/projects + +Not in Ruby's `linear-cli` - favorite the teams/projects you actually +care about, and once any exist, `team list`/`project list` default to +showing just favorites of that kind. `--all` bypasses the favorites +filter only - it doesn't change `team list`'s `--no-mine` or `project +list`'s `--mine`/`--team` scope, it just shows everything within +whatever scope you already asked for. + +[source,sh] +---- +$ lc team favorite CRY +$ lc team list +$ lc team list --all +$ lc team unfavorite CRY +$ lc project favorite Manhattan +$ lc project list +---- + ==== Post a project status update Not in Ruby's `linear-cli` - a status post on a project (Linear's own "Project diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index 1e95467..dca9238 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -162,8 +162,21 @@ defmodule LinearCli.CLI do defp dispatch([:whoami], result, halt), do: run(&Commands.whoami/1, result, halt) defp dispatch([:version], result, halt), do: run(&Commands.version/1, result, halt) defp dispatch([:team, :list], result, halt), do: run(&Commands.team_list/1, result, halt) + + defp dispatch([:team, :favorite], result, halt), + do: run(&Commands.team_favorite/1, result, halt) + + defp dispatch([:team, :unfavorite], result, halt), + do: run(&Commands.team_unfavorite/1, result, halt) + defp dispatch([:project, :list], result, halt), do: run(&Commands.project_list/1, result, halt) + defp dispatch([:project, :favorite], result, halt), + do: run(&Commands.project_favorite/1, result, halt) + + defp dispatch([:project, :unfavorite], result, halt), + do: run(&Commands.project_unfavorite/1, result, halt) + defp dispatch([:project, :update], result, halt), do: run(&Commands.project_update/1, result, halt) @@ -342,8 +355,19 @@ defmodule LinearCli.CLI do name: "list", about: "List teams", flags: [ - no_mine: [long: "--no-mine", help: "List all teams, not just your own"] + no_mine: [long: "--no-mine", help: "List all teams, not just your own"], + all: [long: "--all", help: "Ignore favorites (doesn't affect --no-mine)"] ] + ], + favorite: [ + name: "favorite", + about: "Favorite a team - list defaults to favorites once any exist", + args: [team: [value_name: "TEAM", help: "Team key or id", required: true]] + ], + unfavorite: [ + name: "unfavorite", + about: "Un-favorite a team", + args: [team: [value_name: "TEAM", help: "Team key or id", required: true]] ] ] ], @@ -355,12 +379,35 @@ defmodule LinearCli.CLI do name: "list", about: "List projects", flags: [ - mine: [short: "-m", long: "--mine", help: "Only show my projects"] + mine: [short: "-m", long: "--mine", help: "Only show my projects"], + all: [long: "--all", help: "Ignore favorites (doesn't affect --mine/--team)"] ], options: [ team: [short: "-t", long: "--team", help: "Show projects for only this team"] ] ], + favorite: [ + name: "favorite", + about: "Favorite a project - list defaults to favorites once any exist", + args: [ + project: [ + value_name: "PROJECT", + help: "Project name, URL, ID, or search term", + required: true + ] + ] + ], + unfavorite: [ + name: "unfavorite", + about: "Un-favorite a project", + args: [ + project: [ + value_name: "PROJECT", + help: "Project name, URL, ID, or search term", + required: true + ] + ] + ], update: [ name: "update", about: "Post a status update to a project", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 3dd206f..8b2d7a0 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -5,7 +5,7 @@ defmodule LinearCli.CLI.Commands do """ alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt} - alias LinearCli.{Git, Linear, Profiles} + alias LinearCli.{Favorites, Git, Linear, Profiles} @doc "Ported from commands/whoami.rb." def whoami(%{flags: flags, options: options}) do @@ -37,7 +37,10 @@ defmodule LinearCli.CLI.Commands do result = if flags.no_mine, do: Linear.teams(), else: Linear.my_teams() with {:ok, teams} <- result do - Display.show(teams, %{output: options.output}) + Display.show(filter_favorites(teams, flags.all, "team", & &1.key), %{ + output: options.output + }) + :ok end end @@ -45,7 +48,10 @@ defmodule LinearCli.CLI.Commands do @doc "Ported from commands/project/list.rb. Ruby's `--mine` defaults false." def project_list(%{flags: flags, options: options}) do with {:ok, projects} <- projects_for(flags, options) do - Display.show(projects, %{output: options.output}) + Display.show(filter_favorites(projects, flags.all, "project", & &1.id), %{ + output: options.output + }) + :ok end end @@ -59,6 +65,74 @@ defmodule LinearCli.CLI.Commands do defp projects_for(%{mine: true}, _options), do: Linear.my_projects() defp projects_for(_flags, _options), do: Linear.projects() + @doc """ + New in this port - Ruby has no equivalent. Favorites a team + (`LinearCli.Favorites`) - once any team is favorited, `team list` + defaults to showing just favorites (`--all` overrides). + """ + def team_favorite(%{args: %{team: key}}) do + with {:ok, team} <- Linear.find_team(key) do + Favorites.add("team", team.key) + Prompt.ok("Favorited team #{team.key}") + :ok + end + end + + @doc "New in this port - Ruby has no equivalent. Un-favorites a team." + def team_unfavorite(%{args: %{team: key}}) do + with {:ok, team} <- Linear.find_team(key) do + Favorites.remove("team", team.key) + Prompt.ok("Un-favorited team #{team.key}") + :ok + end + end + + @doc """ + New in this port - Ruby has no equivalent. Favorites a project + (`LinearCli.Favorites`), resolved the same way `project update`'s + `PROJECT` is - against every project in the workspace, prompting if + ambiguous. Once any project is favorited, `project list` defaults to + showing just favorites (`--all` overrides). + """ + def project_favorite(%{args: %{project: search}}) do + with {:ok, projects} <- Linear.projects(), + project when not is_nil(project) <- Projects.project_for(projects, search) do + Favorites.add("project", project.id) + Prompt.ok("Favorited project #{project.name}") + :ok + else + nil -> {:error, {:smells_bad, "No project found matching #{search}"}} + {:error, reason} -> {:error, reason} + end + end + + @doc "New in this port - Ruby has no equivalent. Un-favorites a project." + def project_unfavorite(%{args: %{project: search}}) do + with {:ok, projects} <- Linear.projects(), + project when not is_nil(project) <- Projects.project_for(projects, search) do + Favorites.remove("project", project.id) + Prompt.ok("Un-favorited project #{project.name}") + :ok + else + nil -> {:error, {:smells_bad, "No project found matching #{search}"}} + {:error, reason} -> {:error, reason} + end + end + + # Once any favorite of `kind` exists, narrows `records` down to just + # those (matched via `key_fun`) - invisible to anyone who's never + # favorited anything, since an empty favorites list leaves `records` + # untouched. `all?` (the new `--all` flag) always shows everything, + # bypassing the favorites lookup entirely. + defp filter_favorites(records, true, _kind, _key_fun), do: records + + defp filter_favorites(records, _all?, kind, key_fun) do + case Favorites.list(kind) do + [] -> records + favorite_values -> Enum.filter(records, &(key_fun.(&1) in favorite_values)) + end + end + @doc """ New in this port - Ruby has no equivalent. Posts a status update (Linear's own "Project Update" feature - a journal-style status post, diff --git a/app/lib/linear_cli/favorites.ex b/app/lib/linear_cli/favorites.ex new file mode 100644 index 0000000..201cfc7 --- /dev/null +++ b/app/lib/linear_cli/favorites.ex @@ -0,0 +1,94 @@ +defmodule LinearCli.Favorites do + @moduledoc """ + Favorited teams/projects, persisted in the same local SQLite file + `LinearCli.Profiles` uses (`:profiles_db_path` - see + `config/runtime.exs`), in their own `favorites` table. Reuses + `LinearCli.Profiles`'s exact pattern (raw `Exqlite.Sqlite3`, one + connection open/ensure-schema/close per call, no `Ecto.Repo`) rather + than sharing code with it - see `documents/phase-10-plan.adoc` for why + this stays its own module against the same file instead of a change to + `LinearCli.Profiles` itself. + + `list/1`, once non-empty for a given `kind`, is what + `LinearCli.CLI.Commands.team_list/1`/`project_list/1` filter their + results down to by default (a new `--all` flag opts back out). + + New in this port - Ruby has no equivalent. + """ + + alias Exqlite.Sqlite3 + + @doc "Favorites `value` under `kind` (`\"team\"` or `\"project\"`). A no-op if already favorited." + @spec add(String.t(), String.t()) :: :ok + def add(kind, value) do + with_db(fn conn -> + exec(conn, "INSERT OR IGNORE INTO favorites (kind, value) VALUES (?, ?)", [kind, value]) + end) + end + + @doc "Un-favorites `value` under `kind`. A no-op if it wasn't favorited." + @spec remove(String.t(), String.t()) :: :ok + def remove(kind, value) do + with_db(fn conn -> + exec(conn, "DELETE FROM favorites WHERE kind = ? AND value = ?", [kind, value]) + end) + end + + @doc "Every favorited value under `kind`, ordered by value." + @spec list(String.t()) :: [String.t()] + def list(kind) do + with_db(fn conn -> + conn + |> query("SELECT value FROM favorites WHERE kind = ? ORDER BY value", [kind]) + |> Enum.map(fn [value] -> value end) + end) + end + + defp with_db(fun) do + path = db_path() + File.mkdir_p!(Path.dirname(path)) + {:ok, conn} = Sqlite3.open(path) + + try do + ensure_schema!(conn) + fun.(conn) + after + Sqlite3.close(conn) + end + end + + defp ensure_schema!(conn) do + :ok = + exec( + conn, + """ + CREATE TABLE IF NOT EXISTS favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + value TEXT NOT NULL, + UNIQUE(kind, value) + ) + """, + [] + ) + end + + defp exec(conn, sql, []), do: Sqlite3.execute(conn, sql) + + defp exec(conn, sql, params) do + with {:ok, stmt} <- Sqlite3.prepare(conn, sql), + :ok <- Sqlite3.bind(stmt, params), + :done <- Sqlite3.step(conn, stmt) do + :ok + end + end + + defp query(conn, sql, params) do + {:ok, stmt} = Sqlite3.prepare(conn, sql) + :ok = Sqlite3.bind(stmt, params) + {:ok, rows} = Sqlite3.fetch_all(conn, stmt) + rows + end + + defp db_path, do: Application.fetch_env!(:linear_cli, :profiles_db_path) +end diff --git a/app/test/linear_cli/cli/favorites_commands_test.exs b/app/test/linear_cli/cli/favorites_commands_test.exs new file mode 100644 index 0000000..bda2241 --- /dev/null +++ b/app/test/linear_cli/cli/favorites_commands_test.exs @@ -0,0 +1,172 @@ +defmodule LinearCli.CLI.FavoritesCommandsTest do + # Not async: shares LinearCli.Profiles/LinearCli.Favorites' one sqlite + # file (config :linear_cli, :profiles_db_path) - `setup` below deletes + # it fresh before each test instead. + use ExUnit.Case, async: false + import ExUnit.CaptureIO + + setup do + path = Application.fetch_env!(:linear_cli, :profiles_db_path) + File.rm(path) + + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"query" => query} = Jason.decode!(body) + respond(conn, query) + end) + + :ok + end + + defp respond(conn, query) do + cond do + query =~ "team(id: $id)" -> + Req.Test.json(conn, %{ + "data" => %{"team" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}} + }) + + query =~ "teams(" -> + Req.Test.json(conn, %{ + "data" => %{ + "teams" => %{ + "edges" => [ + %{ + "node" => %{"id" => "t1", "key" => "ENG", "name" => "Engineering"}, + "cursor" => "c1" + }, + %{"node" => %{"id" => "t2", "key" => "OPS", "name" => "Ops"}, "cursor" => "c2"} + ], + "pageInfo" => %{"hasNextPage" => false} + } + } + }) + + query =~ "viewer" -> + Req.Test.json(conn, %{ + "data" => %{ + "viewer" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@example.com", + "teams" => %{"nodes" => [%{"id" => "t1", "key" => "ENG", "name" => "Engineering"}]} + } + } + }) + + query =~ "projects(" -> + Req.Test.json(conn, %{ + "data" => %{ + "projects" => %{ + "edges" => [ + %{ + "node" => %{ + "id" => "p1", + "name" => "Manhattan", + "slugId" => "abc", + "url" => "https://linear.app/x/project/manhattan-abc" + }, + "cursor" => "c1" + }, + %{ + "node" => %{ + "id" => "p2", + "name" => "Platform Cleanup", + "slugId" => "def", + "url" => "https://linear.app/x/project/platform-cleanup-def" + }, + "cursor" => "c2" + } + ], + "pageInfo" => %{"hasNextPage" => false} + } + } + }) + end + end + + describe "team favorite/unfavorite" do + test "favorites a team by key" do + assert capture_io(fn -> assert :ok = LinearCli.CLI.main(["team", "favorite", "ENG"]) end) =~ + "Favorited team ENG" + + assert LinearCli.Favorites.list("team") == ["ENG"] + end + + test "un-favorites a team by key" do + LinearCli.Favorites.add("team", "ENG") + + assert capture_io(fn -> assert :ok = LinearCli.CLI.main(["team", "unfavorite", "ENG"]) end) =~ + "Un-favorited team ENG" + + assert LinearCli.Favorites.list("team") == [] + end + end + + describe "project favorite/unfavorite" do + test "resolves the project by name and favorites its id" do + assert capture_io(fn -> + assert :ok = LinearCli.CLI.main(["project", "favorite", "Manhattan"]) + end) =~ "Favorited project Manhattan" + + assert LinearCli.Favorites.list("project") == ["p1"] + end + + test "un-favorites a project by name" do + LinearCli.Favorites.add("project", "p1") + + assert capture_io(fn -> + assert :ok = LinearCli.CLI.main(["project", "unfavorite", "Manhattan"]) + end) =~ "Un-favorited project Manhattan" + + assert LinearCli.Favorites.list("project") == [] + end + end + + describe "team list favorites filtering" do + test "with no favorites, --no-mine still lists every team unchanged" do + output = capture_io(fn -> LinearCli.CLI.main(["team", "list", "--no-mine"]) end) + assert output =~ "Engineering" + assert output =~ "Ops" + end + + test "once a team is favorited, --no-mine only shows favorites" do + LinearCli.Favorites.add("team", "OPS") + + output = capture_io(fn -> LinearCli.CLI.main(["team", "list", "--no-mine"]) end) + assert output =~ "Ops" + refute output =~ "Engineering" + end + + test "--all overrides the favorites filter" do + LinearCli.Favorites.add("team", "OPS") + + output = capture_io(fn -> LinearCli.CLI.main(["team", "list", "--no-mine", "--all"]) end) + assert output =~ "Ops" + assert output =~ "Engineering" + end + end + + describe "project list favorites filtering" do + test "with no favorites, lists every project unchanged" do + output = capture_io(fn -> LinearCli.CLI.main(["project", "list"]) end) + assert output =~ "Manhattan" + assert output =~ "Platform Cleanup" + end + + test "once a project is favorited, only it is shown" do + LinearCli.Favorites.add("project", "p1") + + output = capture_io(fn -> LinearCli.CLI.main(["project", "list"]) end) + assert output =~ "Manhattan" + refute output =~ "Platform Cleanup" + end + + test "--all overrides the favorites filter" do + LinearCli.Favorites.add("project", "p1") + + output = capture_io(fn -> LinearCli.CLI.main(["project", "list", "--all"]) end) + assert output =~ "Manhattan" + assert output =~ "Platform Cleanup" + end + end +end diff --git a/app/test/linear_cli/favorites_test.exs b/app/test/linear_cli/favorites_test.exs new file mode 100644 index 0000000..435f0d2 --- /dev/null +++ b/app/test/linear_cli/favorites_test.exs @@ -0,0 +1,58 @@ +defmodule LinearCli.FavoritesTest do + # Not async: shares the one sqlite file at `config :linear_cli, + # :profiles_db_path` with LinearCli.ProfilesTest - `setup` below deletes + # it fresh before each test instead. + use ExUnit.Case, async: false + + alias LinearCli.Favorites + + setup do + path = Application.fetch_env!(:linear_cli, :profiles_db_path) + File.rm(path) + :ok + end + + describe "add/2 and list/1" do + test "favorites a value under a kind and lists it back" do + :ok = Favorites.add("team", "CRY") + assert Favorites.list("team") == ["CRY"] + end + + test "kinds are independent" do + :ok = Favorites.add("team", "CRY") + :ok = Favorites.add("project", "p1") + + assert Favorites.list("team") == ["CRY"] + assert Favorites.list("project") == ["p1"] + end + + test "favoriting the same value twice is a no-op, not an error" do + :ok = Favorites.add("team", "CRY") + :ok = Favorites.add("team", "CRY") + assert Favorites.list("team") == ["CRY"] + end + + test "lists multiple favorites ordered by value" do + :ok = Favorites.add("team", "ENG") + :ok = Favorites.add("team", "CRY") + + assert Favorites.list("team") == ["CRY", "ENG"] + end + + test "an unknown kind has no favorites" do + assert Favorites.list("nope") == [] + end + end + + describe "remove/2" do + test "un-favorites a value" do + :ok = Favorites.add("team", "CRY") + :ok = Favorites.remove("team", "CRY") + assert Favorites.list("team") == [] + end + + test "removing a value that was never favorited is a no-op" do + assert :ok = Favorites.remove("team", "nope") + end + end +end diff --git a/documents/phase-10-plan.adoc b/documents/phase-10-plan.adoc new file mode 100644 index 0000000..fe44b95 --- /dev/null +++ b/documents/phase-10-plan.adoc @@ -0,0 +1,140 @@ += {my-title} +Tj Vanderpoel (bougyman) +:revdate: Aug 10, 2026 +:my-title: Phase 10 plan: favorite teams/projects, and filtering list views by them +:icons: font +:env-github: +ifdef::env-github[] +:tip-caption: :bulb: +:note-caption: :information_source: +:important-caption: :heavy_exclamation_mark: +:caution-caption: :fire: +:warning-caption: :warning: +endif::[] +:toc: + +== Goal + +`team list`/`project list` always show every team/project in the +workspace. For anyone who only actually cares about one or two, that's +noise on every single invocation. Let a team/project be marked +"favorited," and once any favorites of that kind exist, default the +corresponding list to just those - a new `--all` flag opts back out to +today's full list. Nobody who's never favorited anything sees any +difference at all. + +This also sets up Phase 11: bare issue numbers (`lc issue develop 1234`) +will offer a sensible team prompt built from favorited teams instead of +either guessing or hard-erroring when there's no active profile. + +== Decisions (reached in full agreement before implementation) + +1. *One `favorites` table, not two* - teams and projects are the exact + same shape here (a kind + a value), so one table with a `kind` column + (`"team"` | `"project"`) avoids duplicating identical CRUD for two + structurally identical concepts. +2. *Store resolved values, not raw search text*: `project favorite + ` resolves `PROJECT` the same way every other command already + does (`LinearCli.CLI.Projects.project_for/2` against + `LinearCli.Linear.projects/0`, prompting to disambiguate if needed) and + stores the resolved project's `id` - not the raw search string. A + stored id needs no re-fuzzy-matching every time a list gets filtered; + a stored search term would. Teams don't need this same care - a team's + `key` (via `LinearCli.Linear.find_team/1`, same as `WhatFor.team_for/1` + uses) is already exact and unambiguous, so that's what gets stored for + `kind: "team"`. +3. *Same sqlite file as profiles, new table* - `LinearCli.Favorites` + reuses `LinearCli.Profiles`'s exact pattern (raw `Exqlite.Sqlite3`, one + connection open/ensure-schema/close per call, no `Ecto.Repo`) against + the *same* file (`config :linear_cli, :profiles_db_path` from Phase 9) + rather than a second small file - it's the same category of local CLI + settings. Each module only ever ensures its *own* table + (`CREATE TABLE IF NOT EXISTS favorites (...)` vs `... profiles (...)`) + on its own connection open - naturally idempotent, no coordination + needed between the two modules sharing one file, and no change to + Phase 9's `LinearCli.Profiles` itself. +4. *Favorites-only by default once any exist, `--all` to see everything* + - confirmed directly: once a favorite of a given kind exists, + `team list`/`project list` filter to just those; `--all` (new flag on + both) shows the unfiltered list regardless. Mirrors the existing + `--mine`/`--no-mine` flag-pair style already used by both commands. +5. *Verbs live under each resource*, not a separate top-level `favorite` + command - `lc team favorite/unfavorite `, + `lc project favorite/unfavorite ` - matching how `list` + already nests under `team`/`project` rather than existing as its own + command. + +== Schema + +[source,sql] +---- +CREATE TABLE IF NOT EXISTS favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + value TEXT NOT NULL, + UNIQUE(kind, value) +); +---- + +`kind: "team"` -> `value` is a team key (e.g. `"CRY"`). `kind: "project"` +-> `value` is a project id. + +== Building blocks + +* `LinearCli.Favorites` (new, peer to `LinearCli.Profiles`, same file): + `add(kind, value)`, `remove(kind, value)`, `list(kind)` -> `[String.t()]`. +* CLI additions to `lib/linear_cli/cli.ex`'s `team:`/`project:` specs: + `favorite`/`unfavorite` subcommands (single required arg, `TEAM`/ + `PROJECT` respectively - resolved the same way other commands resolve + those args), plus a new `--all` flag on `list` for both. +* `Commands.team_favorite/1`/`team_unfavorite/1` (resolve via + `Linear.find_team/1`, then `Favorites.add/remove("team", team.key)`); + `Commands.project_favorite/1`/`project_unfavorite/1` (resolve via + `Projects.project_for/2` against `Linear.projects/0`, then + `Favorites.add/remove("project", project.id)`). +* `Commands.team_list/1`/`project_list/1`: after fetching the full + list as today, if `--all` wasn't given and `Favorites.list/1` for that + kind is non-empty, filter the fetched list down to matches (`key in + favorite_keys` for teams, `id in favorite_ids` for projects) before + displaying. + +== Tests + +* `LinearCli.FavoritesTest` (new, mirroring `LinearCli.ProfilesTest`'s own + shape/isolation: not async, `setup` deletes the shared db file first) - + `add/2`, `remove/2`, `list/1` per kind, uniqueness (favoriting the same + value twice is a no-op, not an error - unlike `Profiles.create/2`'s + duplicate-name rejection, there's no meaningful "already favorited" + failure case here). +* `Commands.team_list/1`/`project_list/1`: favorites-only by default once + one exists; `--all` shows everything regardless; unchanged (shows + everything) with zero favorites - explicitly asserting the no-favorites + case is byte-for-byte what it is today, so this is provably invisible + to anyone who never favorites anything. +* `Commands.team_favorite/1`/`unfavorite/1` and the project equivalents: + resolve-then-store, resolve-then-remove. + +== Docs + +Short new subsection in `Readme.adoc`, near "Default team/project +(profiles)": `lc team favorite/unfavorite`, `lc project +favorite/unfavorite`, and a one-line note that `list` defaults to +favorites once any exist (`--all` for everything). + +== Sequencing + +1. `LinearCli.Favorites` + its own tests. +2. `favorite`/`unfavorite` subcommands + tests - no change to `list`'s + output yet. +3. Wire the favorites-only default + `--all` into `team_list/1`/ + `project_list/1` + tests, including the explicit "zero favorites -> + unchanged" case. +4. `Readme.adoc`. +5. Manual end-to-end verification: favorite one team, confirm `lc team + list` now shows only it, confirm `lc team list --all` still shows + everything, confirm `lc project list` (no project favorites yet) is + still unfiltered. + +Standard workflow from here: file a GitHub issue for this phase, branch +from it, commit, open the PR - no direct-to-main commits. Phase 11 (bare +issue numbers via favorited-team prompts) depends on this landing first. diff --git a/documents/phase-11-plan.adoc b/documents/phase-11-plan.adoc new file mode 100644 index 0000000..0c65c8c --- /dev/null +++ b/documents/phase-11-plan.adoc @@ -0,0 +1,186 @@ += {my-title} +Tj Vanderpoel (bougyman) +:revdate: Aug 10, 2026 +:my-title: Phase 11 plan: bare issue numbers, resolved via profile then favorited teams +:icons: font +:env-github: +ifdef::env-github[] +:tip-caption: :bulb: +:note-caption: :information_source: +:important-caption: :heavy_exclamation_mark: +:caution-caption: :fire: +:warning-caption: :warning: +endif::[] +:toc: + +== Goal + +Every issue-identifier argument today needs the full team-prefixed form +(`CRY-1234`) - Linear's own `issue(id: $id)` API field resolves either a +UUID or that full human identifier, never a bare number on its own +(verified below). With Phase 9's profiles in place, a profile already +carries a default team - if one's active, `lc issue develop 1234` should +work exactly like `lc issue develop CRY-1234`, inferring the `CRY-` prefix +from the active profile's team. + +Without an active profile (or one with no team set), this should *not* +be a hard error. Phase 10 gives every user a set of favorited teams - +reuse that here: fall back to a sensible prompt built from favorited +teams (single favorite -> just use it, several -> pick one, none -> +fall back further to prompting across every team the user belongs to, +the same shape `WhatFor.ask_for_team/0` already uses for the unrelated +"no `--team` given at all" case). A bare number should basically always +resolve to *something* sensible, never a dead end. + +Note this phase only concerns *teams* - a Linear issue identifier is +always `TEAM-NUMBER`; there's no project component to an issue id at all, +so Phase 10's favorited *projects* have no role here. They stay useful for +`project list`'s own filtering and remain a natural candidate for +improving `Projects.project_for/2`'s own disambiguation prompts later - +not part of this phase. + +== Decisions (reached in full agreement before implementation) + +1. *What counts as "bare"*: the whole argument matches `~r/^\d+$/` - just + digits, nothing else. Anything already containing a `-` (or any other + non-digit), or not looking like an id at all, passes through completely + unchanged - existing full-identifier usage (`CRY-1234`) and UUID usage + are both untouched. +2. *Team resolution order for a bare number*, never a hard error: + .. Phase 9's active profile, if it has a team - `Profiles.default_team/0`, + exactly as today's plan already assumed. + .. Otherwise, favorited teams (Phase 10) - `Favorites.list("team")`: + one favorite used directly, several prompted + (`LinearCli.CLI.Prompt.select/2`), same single-vs-many shape + `WhatFor.ask_for_team/0` already uses for its own unrelated case. + .. Otherwise (no favorites either), fall back to prompting across + *every* team the user belongs to - functionally + `WhatFor.ask_for_team/0`'s own full-list behavior, reused rather + than reimplemented. + A bare number only ever fails if the user has literally no teams at + all - the same edge case `ask_for_team/0` already raises on today. +3. *A new, bare-number-specific helper, not a change to `WhatFor`*: Phase + 9's profile-default behavior for `--team`/`--project` flags, and + `ask_for_team/0`'s own behavior for a fully-omitted `--team`, are both + unchanged. This phase adds a new resolution path used only when the + argument itself was a bare number - `WhatFor.ask_for_team/0` gets + reused as the last-resort fallback (step iii above), not modified. +4. *Lives in the CLI layer, not `LinearCli.Linear`*: every domain/Ash + module (`Issue`, `Team`, `Project`, ...) is profile/favorites-unaware + today - Phase 9 kept that boundary intact by resolving defaults in + `LinearCli.CLI.IssueHelpers`/`Commands`, never inside `LinearCli.Linear` + itself. This phase keeps that boundary too, even though it means + touching two call sites instead of the one lower-level choke point + identified below. +5. *One shared function, not duplicated logic*: a single + `LinearCli.CLI.IssueHelpers.expand_issue_id/1`, used at both of the + call sites identified below. + +== Verified: every consumption path, and why there's no single lower choke point + +Traced directly (not assumed) where an issue id string actually goes, +starting from each Optimus arg in `lib/linear_cli/cli.ex`: + +* `issue develop ` and `issue pr ` (single named args, + `cli.ex:470-487`) both dispatch straight to + `IssueHelpers.gimme_da_issue!/2` (`commands.ex:243-253`, `288-301`). +* `issue take ` (`cli.ex:488-492`, captured via + `allow_unknown_args: true` + `result.unknown` - Optimus has no variadic- + positional-arg type, per that section's own existing comment) loops + every id through `IssueHelpers.gimme_da_issue!/2` too + (`commands.ex:320-349`). +* `gimme_da_issue!/2` itself (`issue_helpers.ex`) calls + `Linear.issues(%{ids: [issue_id]})` internally - so all three of the + above ultimately converge on `Linear.issues/1`'s `:ids` list already. +* `issue update ` and `issue list`'s own positional ids (both via + `result.unknown`, same `allow_unknown_args: true` pattern, + `cli.ex:419-426`/`493-518`) call `Linear.issues(%{ids: ...})` + *directly* - `commands.ex:154-171`/`366-386` - never routing through + `gimme_da_issue!/2` at all. +* `Linear.issues/1`'s `:ids` and `gimme_da_issue!/2` both bottom out in + `LinearCli.Linear.Issue.Read.List.find_by_ids/1`, which fans out one + GraphQL call per id: ++ +[source,elixir] +---- +defp find_document do + "query($id: String!) { issue(id: $id) { #{Issue.full_fields()} } }" +end + +defp fetch_one(id) do + case Api.call(find_document(), %{"id" => String.upcase(id)}) do +---- ++ +No local parsing happens here or anywhere else in the app (grepped for +`-\d+`-style patterns and `-`-splitting - the only hits are unrelated: a +PR-scope regex in `what_for.ex` and project-name slugifying in +`project.ex`) - the raw string, just uppercased, goes straight to Linear's +own `issue(id: $id)`, which needs the full identifier or a UUID. *This* is +the true single lowest-level choke point every path shares - but it's +inside `LinearCli.Linear`, the domain layer Decision 4 deliberately keeps +profile/favorites-unaware, so it's not where the expansion belongs despite +being the one spot that would touch the least code. +* `bin/` wrapper scripts (`lcls`, `lcreate`, `lclose`, `lcomment`, `lproj`) + are all thin `exec lc ...` passthroughs - none manipulate ids + themselves, so they get this for free once the CLI layer handles it. + +Net: two real call sites, not one - `IssueHelpers.gimme_da_issue!/2` +(covers develop/pr/take, and issue create's own post-create self-assign +prompt, for free, since all of them already call it) and the two +`Commands` functions that call `Linear.issues/1` directly +(`issue_list/1`, `issue_update/1`). + +== Building blocks + +* `LinearCli.CLI.IssueHelpers.expand_issue_id/1` (new, public): given a raw + id string, `~r/^\d+$/` match -> `"#{team_key}-#{id}"`, `team_key` from a + new private `resolve_bare_team/0` implementing Decision 2's three-step + order (`Profiles.default_team/0` -> `Favorites.list("team")` -> + `WhatFor.ask_for_team/0`); no match -> the id returned unchanged. +* `IssueHelpers.gimme_da_issue!/2`: one line at the top, + `issue_id = expand_issue_id(issue_id)`. +* `Commands.issue_list/1`: map `expand_issue_id/1` over `ids` (the + `unknown` list) before it goes into `input`. +* `Commands.issue_update/1`: same, over `issue_ids`, before + `Linear.issues(%{ids: issue_ids})`. + +== Tests + +* `LinearCli.CLI.IssueHelpersTest`: `expand_issue_id/1` - bare number with + an active profile's team (no prompt); bare number with no active + profile but one favorited team (used directly, no prompt); bare number + with several favorited teams (prompts, picks the selected one); bare + number with no profile and no favorites (falls through to + `ask_for_team/0`'s own full-list prompt); already-prefixed id and a + UUID both pass through unchanged regardless of any profile/favorites. +* One test each (new or added to the existing describe blocks) for + `issue_develop/2`/`issue_pr/2`/`issue_take/2` confirming a bare number + resolves against the active profile's team (asserting on the captured + `variables["id"]` sent to the stub, same technique already used in + `LinearCli.CLI.ProfileDefaultsTest`). +* Same for `Commands.issue_list/1` and `Commands.issue_update/1`. + +== Docs + +Short addition to `Readme.adoc`'s existing "Default team/project +(profiles)" subsection (Phase 9) - a line noting issue commands also +accept a bare number (`lc issue develop 1234`), resolved via the active +profile, then favorited teams, then a prompt across every team. + +== Sequencing + +Depends on Phase 10 (`LinearCli.Favorites`) landing first. + +1. `IssueHelpers.expand_issue_id/1` (with `resolve_bare_team/0`'s full + three-step order) + its own tests - no call sites wired yet. +2. Wire it into `gimme_da_issue!/2` (covers develop/pr/take in one change) + + tests. +3. Wire it into `Commands.issue_list/1`/`issue_update/1` + tests. +4. `Readme.adoc`. +5. Manual end-to-end verification: with an active profile's team, a + favorited team (no active profile), and neither (falls through to the + full team prompt) - `lc issue develop 1234` resolves sensibly in all + three cases. + +Standard workflow from here: file a GitHub issue for this phase, branch +from it, commit, open the PR - no direct-to-main commits.