diff --git a/Readme.adoc b/Readme.adoc index 659e066..7fd644d 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -244,6 +244,24 @@ $ lcomment CRY-1234 CRY-3 <5> $ lc issue update --close --reason "These were closable" CRY-1234 CRY-2 ---- +==== Default team/project (profiles) + +Not in Ruby's `linear-cli` - save a named team/project bundle once, then +switch to it instead of passing `--team`/`--project` on every `issue +create`/`issue list`. + +[source,sh] +---- +$ lc profile create manhattan --team CRY --project Manhattan +$ lc profile use manhattan +$ lc profile list +$ lc profile show +$ lc profile delete manhattan +---- + +An explicit `--team`/`--project` on the command line always overrides the +active profile. + ==== 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/.gitignore b/app/.gitignore index 433eee3..07b9c1a 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -32,3 +32,8 @@ app-*.tar # Local dev SQLite db for Oban (daemon run mode only). /oban_dev.db* +# Local SQLite db for LinearCli.Profiles (dev and test - unlike Oban's, +# needed by every interactive invocation, not just the daemon). +/profiles_dev.db* +/profiles_test.db* + diff --git a/app/config/runtime.exs b/app/config/runtime.exs index a0f861e..c13c909 100644 --- a/app/config/runtime.exs +++ b/app/config/runtime.exs @@ -31,3 +31,19 @@ config :linear_cli, LinearCli.ObanRepo.Postgres, username: System.get_env("LINEAR_CLI_PG_USER", "postgres"), password: System.get_env("LINEAR_CLI_PG_PASSWORD", ""), database: System.get_env("LINEAR_CLI_PG_DATABASE", default_pg_database) + +# LinearCli.Profiles' own SQLite file - separate from ObanRepo's above +# (different concern, needed by every interactive invocation, not just the +# daemon). Unlike ObanRepo, there's no pooled Ecto.Repo behind this - +# LinearCli.Profiles opens/closes its own connection per call - so :test +# can't use ":memory:" the way ObanRepo's test config does: an in-memory +# database is wiped the moment that connection closes, which would be +# every single call. +default_profiles_path = + case config_env() do + :test -> Path.expand("../profiles_test.db", __DIR__) + :dev -> Path.expand("../profiles_dev.db", __DIR__) + :prod -> Path.join(System.user_home!(), ".linear_cli/profiles.db") + end + +config :linear_cli, :profiles_db_path, default_profiles_path diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index bc1c414..1e95467 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -102,7 +102,8 @@ defmodule LinearCli.CLI do "pull-request" => "pr" }, "team" => %{"l" => "list", "ls" => "list"}, - "project" => %{"l" => "list", "ls" => "list"} + "project" => %{"l" => "list", "ls" => "list"}, + "profile" => %{"l" => "list", "ls" => "list"} } @doc false @@ -166,6 +167,16 @@ defmodule LinearCli.CLI do defp dispatch([:project, :update], result, halt), do: run(&Commands.project_update/1, result, halt) + defp dispatch([:profile, :create], result, halt), + do: run(&Commands.profile_create/1, result, halt) + + defp dispatch([:profile, :list], result, halt), do: run(&Commands.profile_list/1, result, halt) + defp dispatch([:profile, :use], result, halt), do: run(&Commands.profile_use/1, result, halt) + defp dispatch([:profile, :show], result, halt), do: run(&Commands.profile_show/1, result, halt) + + defp dispatch([:profile, :delete], result, halt), + do: run(&Commands.profile_delete/1, result, halt) + defp dispatch([:issue, :list], result, halt), do: run(&Commands.issue_list/1, result, halt) defp dispatch([:issue, :create], result, halt), do: run(&Commands.issue_create/1, result, halt) @@ -374,6 +385,37 @@ defmodule LinearCli.CLI do ] ] ], + profile: [ + name: "profile", + about: "Manage saved team/project profiles", + subcommands: [ + create: [ + name: "create", + about: "Save a new profile", + args: [name: [value_name: "NAME", help: "Profile name", required: true]], + options: [ + team: [short: "-t", long: "--team", help: "Default team for this profile"], + project: [ + short: "-p", + long: "--project", + help: "Default project for this profile" + ] + ] + ], + list: [name: "list", about: "List saved profiles"], + use: [ + name: "use", + about: "Switch to a saved profile", + args: [name: [value_name: "NAME", help: "Profile name", required: true]] + ], + show: [name: "show", about: "Show the active profile"], + delete: [ + name: "delete", + about: "Delete a saved profile", + args: [name: [value_name: "NAME", help: "Profile name", required: true]] + ] + ] + ], issue: [ name: "issue", about: "Manage issues", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index c31f7f8..3dd206f 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} + alias LinearCli.{Git, Linear, Profiles} @doc "Ported from commands/whoami.rb." def whoami(%{flags: flags, options: options}) do @@ -79,6 +79,66 @@ defmodule LinearCli.CLI.Commands do end end + @doc """ + New in this port - Ruby has no equivalent. Saves a new named + team/project bundle (`LinearCli.Profiles.create/2`) that `profile use` + can later switch to. + """ + def profile_create(%{args: %{name: name}, options: options}) do + case Profiles.create(name, team: options.team, project: options.project) do + {:ok, profile} -> + Display.show(profile, %{output: options.output}) + :ok + + {:error, reason} -> + {:error, reason} + end + end + + @doc "New in this port - Ruby has no equivalent. Lists every saved profile." + def profile_list(%{options: options}) do + Display.show(Profiles.list(), %{output: options.output}) + :ok + end + + @doc """ + New in this port - Ruby has no equivalent. Switches the active profile - + its team/project become the defaults `issue create`/`issue list` fall + back to when `--team`/`--project` are omitted. + """ + def profile_use(%{args: %{name: name}}) do + case Profiles.activate(name) do + :ok -> + Prompt.ok("Switched to profile #{name}") + :ok + + {:error, :not_found} -> + {:error, {:smells_bad, "No profile named #{name}"}} + end + end + + @doc "New in this port - Ruby has no equivalent. Shows the active profile, if any." + def profile_show(%{options: options}) do + case Profiles.active() do + nil -> Prompt.warn("No active profile") + profile -> Display.show(profile, %{output: options.output}) + end + + :ok + end + + @doc "New in this port - Ruby has no equivalent. Deletes a saved profile." + def profile_delete(%{args: %{name: name}}) do + case Profiles.delete(name) do + :ok -> + Prompt.ok("Deleted profile #{name}") + :ok + + {:error, :not_found} -> + {:error, {:smells_bad, "No profile named #{name}"}} + end + end + @doc """ Ported from commands/issue/list.rb + operations/issue/list.rb. @@ -86,16 +146,20 @@ defmodule LinearCli.CLI.Commands do does - against every project in the workspace (`Project.all`, not team-scoped), prompting interactively when the search is ambiguous or omitted-but-requested (`-p -`). Only resolved at all when `--project` was - actually given - unlike `issue create`/`issue update`, a bare `issue list` - applies no project filter and never prompts. + actually given (or `LinearCli.Profiles.default_project/0` supplies one) - + unlike `issue create`/`issue update`, a bare `issue list` with no active + profile applies no project filter and never prompts. `--team`/`--project` + passed explicitly always win over the active profile. """ def issue_list(%{flags: flags, options: options, unknown: ids}) do - with {:ok, project_id} <- resolve_project_id(options.project) do + team_key = options.team || Profiles.default_team() + + with {:ok, project_id} <- resolve_project_id(options.project || Profiles.default_project()) do input = %{ ids: ids, mine: !flags.no_mine, unassigned: flags.unassigned, - team_key: options.team, + team_key: team_key, project_id: project_id } diff --git a/app/lib/linear_cli/cli/display.ex b/app/lib/linear_cli/cli/display.ex index b936b08..301a140 100644 --- a/app/lib/linear_cli/cli/display.ex +++ b/app/lib/linear_cli/cli/display.ex @@ -8,6 +8,7 @@ defmodule LinearCli.CLI.Display do """ alias LinearCli.Linear.{Issue, Project, ProjectUpdate, Team, User} + alias LinearCli.Profiles.Profile @ash_internal_fields ~w(__meta__ __metadata__ __order__ __lateral_join_source__ aggregates calculations)a @@ -36,6 +37,14 @@ defmodule LinearCli.CLI.Display do IO.puts("Posted#{health}: #{update.url}") end + defp puts_text(%Profile{} = profile, _opts) do + marker = if profile.active, do: "* ", else: " " + + IO.puts( + "#{marker}#{String.pad_trailing(profile.name, 12)} team=#{profile.team || "-"} project=#{profile.project || "-"}" + ) + end + defp puts_text(%User{} = user, opts) do IO.puts(user_line(user, opts)) end diff --git a/app/lib/linear_cli/cli/issue_helpers.ex b/app/lib/linear_cli/cli/issue_helpers.ex index ee95306..4935b14 100644 --- a/app/lib/linear_cli/cli/issue_helpers.ex +++ b/app/lib/linear_cli/cli/issue_helpers.ex @@ -78,7 +78,7 @@ defmodule LinearCli.CLI.IssueHelpers do """ alias LinearCli.CLI.{Projects, Prompt, WhatFor} - alias LinearCli.Linear + alias LinearCli.{Linear, Profiles} @doc """ Adds a comment to `issue`, resolving `comment` (asking, or opening an @@ -342,7 +342,10 @@ defmodule LinearCli.CLI.IssueHelpers do `LinearCli.CLI.WhatFor`/`LinearCli.CLI.Projects`). `opts` (Ruby's `**options`): `:title`, `:description`, `:team`, `:labels`, - `:project`. + `: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. Ported from `CLI::Issue#make_da_issue!`. """ @@ -350,11 +353,11 @@ defmodule LinearCli.CLI.IssueHelpers do def make_da_issue!(opts \\ []) do title = WhatFor.title_for(opts[:title]) description = WhatFor.description_for(opts[:description]) - team = WhatFor.team_for(opts[:team]) + team = WhatFor.team_for(opts[:team] || Profiles.default_team()) labels = WhatFor.labels_for(team, opts[:labels]) with {:ok, projects} <- Linear.projects_by_team(team.id) do - project = Projects.project_for(projects, opts[:project]) + project = Projects.project_for(projects, opts[:project] || Profiles.default_project()) label_ids = Enum.map(labels, & &1.id) params = maybe_put_project_id(%{label_ids: label_ids}, project) diff --git a/app/lib/linear_cli/profiles.ex b/app/lib/linear_cli/profiles.ex new file mode 100644 index 0000000..75a93f1 --- /dev/null +++ b/app/lib/linear_cli/profiles.ex @@ -0,0 +1,185 @@ +defmodule LinearCli.Profiles do + @moduledoc """ + Named team/project bundles ("profiles"), persisted in a small local + SQLite file (`~/.linear_cli/profiles.db` in prod - see + `config/runtime.exs`'s `:profiles_db_path`), with at most one active at a + time - enforced by a partial unique index (`active_idx`), not + application-level bookkeeping. `default_team/0`/`default_project/0` are + what `LinearCli.CLI.IssueHelpers.make_da_issue!/1` and + `LinearCli.CLI.Commands.issue_list/1` fall back to when `--team`/ + `--project` are omitted - see `documents/phase-9-plan.adoc`. + + New in this port - Ruby has no equivalent. Uses `Exqlite.Sqlite3` + directly rather than a new `Ecto.Repo`: `LinearCli.Application`'s + interactive-mode supervisor is deliberately empty (see its own + comments), and adding a pooled repo plus migration ceremony there for a + four-column table is exactly the overhead that emptiness exists to + avoid. Every function here opens its own connection, ensures the + schema, and closes - no long-lived process, nothing to migrate ahead of + time. + """ + + defmodule Profile do + @moduledoc "A saved team/project bundle - see `LinearCli.Profiles`." + defstruct [:id, :name, :team, :project, :active] + end + + alias Exqlite.Sqlite3 + + @doc "Saves a new profile. `opts`: optional `:team`/`:project` search terms." + @spec create(String.t(), keyword()) :: {:ok, %Profile{}} | {:error, term()} + def create(name, opts \\ []) do + team = opts[:team] + project = opts[:project] + + with_db(fn conn -> + case exec(conn, "INSERT INTO profiles (name, team, project) VALUES (?, ?, ?)", [ + name, + team, + project + ]) do + :ok -> + {:ok, id} = Sqlite3.last_insert_rowid(conn) + {:ok, %Profile{id: id, name: name, team: team, project: project, active: false}} + + {:error, reason} -> + {:error, reason} + end + end) + end + + @doc "All saved profiles, ordered by name." + @spec list() :: [%Profile{}] + def list do + with_db(fn conn -> + conn + |> query("SELECT id, name, team, project, active FROM profiles ORDER BY name") + |> Enum.map(&row_to_profile/1) + end) + end + + @doc """ + Switches the active profile to `name`. `{:error, :not_found}` if no such + profile exists - the two updates below run inside a transaction so an + unknown name rolls back instead of leaving no profile active at all. + """ + @spec activate(String.t()) :: :ok | {:error, :not_found} + def activate(name) do + with_db(fn conn -> + :ok = exec(conn, "BEGIN", []) + :ok = exec(conn, "UPDATE profiles SET active = 0", []) + :ok = exec(conn, "UPDATE profiles SET active = 1 WHERE name = ?", [name]) + + case changed?(conn) do + :ok -> + :ok = exec(conn, "COMMIT", []) + :ok + + {:error, :not_found} = error -> + :ok = exec(conn, "ROLLBACK", []) + error + end + end) + end + + @doc "The single active profile, or `nil`." + @spec active() :: %Profile{} | nil + def active do + with_db(fn conn -> + case query(conn, "SELECT id, name, team, project, active FROM profiles WHERE active = 1") do + [row] -> row_to_profile(row) + [] -> nil + end + end) + end + + @doc "Deletes the named profile. `{:error, :not_found}` if no such profile exists." + @spec delete(String.t()) :: :ok | {:error, :not_found} + def delete(name) do + with_db(fn conn -> + :ok = exec(conn, "DELETE FROM profiles WHERE name = ?", [name]) + changed?(conn) + end) + end + + @doc "The active profile's team, or `nil` with no active profile." + @spec default_team() :: String.t() | nil + def default_team, do: active_field(:team) + + @doc "The active profile's project, or `nil` with no active profile." + @spec default_project() :: String.t() | nil + def default_project, do: active_field(:project) + + defp active_field(field) do + case active() do + nil -> nil + profile -> Map.get(profile, field) + end + end + + defp changed?(conn) do + case Sqlite3.changes(conn) do + {:ok, 0} -> {:error, :not_found} + {:ok, _changed} -> :ok + end + end + + defp row_to_profile([id, name, team, project, active]) do + %Profile{id: id, name: name, team: team, project: project, active: active == 1} + 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 profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + team TEXT, + project TEXT, + active INTEGER NOT NULL DEFAULT 0 + ) + """, + [] + ) + + :ok = + exec( + conn, + "CREATE UNIQUE INDEX IF NOT EXISTS profiles_active_idx ON profiles(active) WHERE active = 1", + [] + ) + 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) do + {:ok, stmt} = Sqlite3.prepare(conn, sql) + {: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/profile_defaults_test.exs b/app/test/linear_cli/cli/profile_defaults_test.exs new file mode 100644 index 0000000..f7f31de --- /dev/null +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -0,0 +1,269 @@ +defmodule LinearCli.CLI.ProfileDefaultsTest do + # Not async: shares LinearCli.Profiles' one sqlite file + # (config :linear_cli, :profiles_db_path) with LinearCli.ProfilesTest - + # both async: false, so ExUnit never runs them concurrently with each + # other or with any async: true module that might otherwise stomp on the + # same file. + use ExUnit.Case, async: false + import ExUnit.CaptureIO + + alias LinearCli.CLI.{Commands, IssueHelpers} + alias LinearCli.Profiles + + setup do + path = Application.fetch_env!(:linear_cli, :profiles_db_path) + File.rm(path) + :ok + end + + defp team_map(key), do: %{"id" => "t1", "key" => key, "name" => "Team #{key}"} + + defp project_map(id, name) do + %{ + "id" => id, + "name" => name, + "content" => nil, + "slugId" => "abc", + "description" => nil, + "url" => "https://linear.app/x/project/#{id}" + } + end + + defp all_projects(projects) do + %{ + "data" => %{ + "projects" => %{ + "edges" => Enum.map(projects, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => %{"hasNextPage" => false} + } + } + } + end + + defp team_projects(projects), + do: %{"data" => %{"team" => %{"projects" => %{"nodes" => projects}}}} + + defp label_response(names) do + %{ + "data" => %{ + "issueLabels" => %{ + "edges" => + Enum.map(names, fn name -> + %{ + "node" => %{ + "id" => "l-#{name}", + "name" => name, + "description" => nil, + "isGroup" => false + } + } + end) + } + } + } + end + + defp issue_map(overrides \\ %{}) do + Map.merge( + %{ + "id" => "i1", + "identifier" => "CRY-1", + "title" => "Fix the thing", + "branchName" => "cry-1-fix-the-thing", + "description" => "It is broken", + "assignee" => nil, + "team" => team_map("ENG"), + "comments" => %{"nodes" => []} + }, + overrides + ) + end + + defp issues_response(issues) do + %{ + "data" => %{ + "issues" => %{ + "edges" => Enum.map(issues, &%{"node" => &1, "cursor" => &1["id"]}), + "pageInfo" => %{"hasNextPage" => false} + } + } + } + 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") + :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, "projects(first: $first") -> + Req.Test.json(conn, all_projects([project_map("p1", "Manhattan Rollout")])) + + String.contains?(query, "issues(filter") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([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: [] + } + + output = capture_io(fn -> assert :ok = Commands.issue_list(result) end) + + assert output =~ "CRY-1" + assert_received {:filter, filter} + assert filter["team"] == %{"key" => %{"eq" => "CRY"}} + assert filter["project"] == %{"id" => %{"eq" => "p1"}} + end + + test "an explicit --team/--project still wins over the active profile" do + {:ok, _} = Profiles.create("manhattan", team: "CRY", project: "Manhattan Rollout") + :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, "projects(first: $first") -> + Req.Test.json(conn, all_projects([project_map("p2", "Platform Cleanup")])) + + String.contains?(query, "issues(filter") -> + send(test_pid, {:filter, decoded["variables"]["filter"]}) + Req.Test.json(conn, issues_response([issue_map()])) + + true -> + raise "no stub matched query: #{query}" + end + end) + + result = %{ + flags: %{no_mine: false, unassigned: false, full: false}, + options: %{team: "ENG", project: "Platform Cleanup", output: "text"}, + unknown: [] + } + + capture_io(fn -> assert :ok = Commands.issue_list(result) end) + + assert_received {:filter, filter} + assert filter["team"] == %{"key" => %{"eq" => "ENG"}} + assert filter["project"] == %{"id" => %{"eq" => "p2"}} + end + end + + describe "IssueHelpers.make_da_issue!/1 falls back to the active profile" do + test "uses the active profile's team/project when both are omitted from opts" do + {:ok, _} = Profiles.create("manhattan", team: "ENG", project: "Manhattan Rollout") + :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"] + variables = decoded["variables"] || %{} + + cond do + String.contains?(query, "team(id: $id)") -> + send(test_pid, {:team_key, variables["id"]}) + Req.Test.json(conn, %{"data" => %{"team" => team_map("ENG")}}) + + String.contains?(query, "issueLabels") -> + Req.Test.json(conn, label_response(["urgent"])) + + String.contains?(query, "projects(first: 100)") -> + Req.Test.json(conn, team_projects([project_map("p1", "Manhattan Rollout")])) + + String.contains?(query, "issueCreate") -> + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => issue_map(%{"id" => "i2", "identifier" => "CRY-2"}) + } + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + assert capture_io(fn -> + assert {:ok, %{identifier: "CRY-2"}} = + IssueHelpers.make_da_issue!( + title: "New thing", + description: "Some description", + labels: ["urgent"] + ) + end) == "" + + assert_received {:team_key, "ENG"} + end + + test "an explicit :team still wins over the active profile" do + {:ok, _} = Profiles.create("manhattan", team: "ENG", project: "Manhattan Rollout") + :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"] + variables = decoded["variables"] || %{} + + cond do + String.contains?(query, "team(id: $id)") -> + send(test_pid, {:team_key, variables["id"]}) + Req.Test.json(conn, %{"data" => %{"team" => team_map("PLATFORM")}}) + + String.contains?(query, "issueLabels") -> + Req.Test.json(conn, label_response(["urgent"])) + + String.contains?(query, "projects(first: 100)") -> + Req.Test.json(conn, team_projects([])) + + String.contains?(query, "issueCreate") -> + Req.Test.json(conn, %{ + "data" => %{ + "issueCreate" => %{ + "issue" => issue_map(%{"id" => "i2", "identifier" => "CRY-2"}) + } + } + }) + + true -> + raise "no stub matched query: #{query}" + end + end) + + capture_io(fn -> + IssueHelpers.make_da_issue!( + title: "New thing", + description: "Some description", + labels: ["urgent"], + team: "PLATFORM" + ) + end) + + assert_received {:team_key, "PLATFORM"} + end + end +end diff --git a/app/test/linear_cli/profiles_test.exs b/app/test/linear_cli/profiles_test.exs new file mode 100644 index 0000000..0136890 --- /dev/null +++ b/app/test/linear_cli/profiles_test.exs @@ -0,0 +1,106 @@ +defmodule LinearCli.ProfilesTest do + # Not async: every test in this module shares the one sqlite file at + # `config :linear_cli, :profiles_db_path` (config/runtime.exs) - `setup` + # below deletes it fresh before each test instead. + use ExUnit.Case, async: false + + alias LinearCli.Profiles + alias LinearCli.Profiles.Profile + + setup do + path = Application.fetch_env!(:linear_cli, :profiles_db_path) + File.rm(path) + :ok + end + + describe "create/2 and list/0" do + test "saves a profile with the given team/project and lists it back" do + assert {:ok, %Profile{name: "manhattan", team: "CRY", project: "Manhattan"}} = + Profiles.create("manhattan", team: "CRY", project: "Manhattan") + + assert [%Profile{name: "manhattan", team: "CRY", project: "Manhattan", active: false}] = + Profiles.list() + end + + test "team/project are optional" do + assert {:ok, %Profile{team: nil, project: nil}} = Profiles.create("bare") + end + + test "lists multiple profiles ordered by name" do + {:ok, _} = Profiles.create("zeta") + {:ok, _} = Profiles.create("alpha") + + assert [%Profile{name: "alpha"}, %Profile{name: "zeta"}] = Profiles.list() + end + + test "rejects a duplicate name" do + {:ok, _} = Profiles.create("dup") + assert {:error, _reason} = Profiles.create("dup") + end + end + + describe "activate/1 and active/0" do + test "with no active profile, active/0 returns nil" do + assert Profiles.active() == nil + end + + test "activating a profile makes it the active one" do + {:ok, _} = Profiles.create("manhattan", team: "CRY", project: "Manhattan") + + assert :ok = Profiles.activate("manhattan") + assert %Profile{name: "manhattan", active: true} = Profiles.active() + end + + test "activating a second profile deactivates the first" do + {:ok, _} = Profiles.create("manhattan", team: "CRY") + {:ok, _} = Profiles.create("platform", team: "ENG") + + :ok = Profiles.activate("manhattan") + :ok = Profiles.activate("platform") + + assert %Profile{name: "platform", active: true} = Profiles.active() + assert [manhattan, platform] = Profiles.list() + refute manhattan.active + assert platform.active + end + + test "activating an unknown profile returns :not_found" do + assert {:error, :not_found} = Profiles.activate("nope") + end + + test "activating an unknown profile leaves the current active profile untouched" do + {:ok, _} = Profiles.create("manhattan", team: "CRY") + :ok = Profiles.activate("manhattan") + + assert {:error, :not_found} = Profiles.activate("nope") + assert %Profile{name: "manhattan", active: true} = Profiles.active() + end + end + + describe "delete/1" do + test "removes the profile" do + {:ok, _} = Profiles.create("manhattan") + assert :ok = Profiles.delete("manhattan") + assert Profiles.list() == [] + end + + test "deleting an unknown profile returns :not_found" do + assert {:error, :not_found} = Profiles.delete("nope") + end + end + + describe "default_team/0 and default_project/0" do + test "nil with no active profile" do + assert Profiles.default_team() == nil + assert Profiles.default_project() == nil + end + + test "the active profile's team/project once one is activated" do + {:ok, _} = Profiles.create("manhattan", team: "CRY", project: "Manhattan") + :ok = Profiles.activate("manhattan") + + assert Profiles.default_team() == "CRY" + assert Profiles.default_project() == "Manhattan" + end + end +end diff --git a/app/test/test_helper.exs b/app/test/test_helper.exs index 1ae4948..dff1a1e 100644 --- a/app/test/test_helper.exs +++ b/app/test/test_helper.exs @@ -14,4 +14,16 @@ System.put_env("LINEAR_API_KEY", "test-key") # LINEAR_API_KEY-style race. Application.put_env(:elixir, :ansi_enabled, true) +# LinearCli.Profiles opens/closes its own connection per call rather than +# holding one open for the whole suite (see its moduledoc), so its sqlite +# file at :profiles_db_path outlives any single `mix test` invocation on +# disk. Without this, a stray active profile left behind by an earlier run +# would leak into every other test that touches issue_list/make_da_issue! +# but isn't itself aware of profiles - a suite that isn't deterministic +# against its own previous runs. Individual profile-aware test modules +# (LinearCli.ProfilesTest, LinearCli.CLI.ProfileDefaultsTest) still delete +# it again in their own `setup`, since it comes back the moment any test +# creates a profile. +Application.fetch_env!(:linear_cli, :profiles_db_path) |> File.rm() + ExUnit.start() diff --git a/documents/phase-9-plan.adoc b/documents/phase-9-plan.adoc new file mode 100644 index 0000000..0b34432 --- /dev/null +++ b/documents/phase-9-plan.adoc @@ -0,0 +1,245 @@ += {my-title} +Tj Vanderpoel (bougyman) +:revdate: Aug 10, 2026 +:my-title: Phase 9 plan: profiles - default team/project (toward 1.0) +: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 create`/`issue list` invocation needs `--team`/`--project` or +falls back to interactive prompting (`WhatFor.team_for/1`, +`Projects.project_for/2`) - there's no way to say "I usually work in team +CRY on the Manhattan project" once and have it stick. Ruby's `linear-cli` +never had this either - this is a genuinely new feature, not a port. + +Not a single flat default pair: named, switchable *profiles* - e.g. a +"manhattan" profile (team CRY, project Manhattan) and a "platform" profile +(team ENG, no project) - so someone working across multiple teams/projects +can flip between them with one command instead of re-typing flags or +editing a config file. Stored locally in SQLite, per the explicit +requirement driving this phase. Significant enough in scope (new local data +store, a new top-level command, changed resolution behavior in two existing +commands) that it's the last big piece before a 1.0. + +== Decisions (reached in full agreement before implementation) + +1. *Profiles, not flat defaults*: a `profiles` table, each row a named + bundle of `team`/`project`, with exactly one markable *active* at a time. + `lc profile use ` switches; whichever commands consult a default + read the currently active profile. +2. *A new, separate SQLite store - not Oban's*: Oban's + `LinearCli.ObanRepo.Sqlite` (`~/.linear_cli/oban.db`) is daemon-only and + the wrong lifecycle for this (see verification below). A new file, + `~/.linear_cli/profiles.db`, sibling to it, same `System.user_home!()` + convention (`config/runtime.exs:16`). +3. *Raw `Exqlite.Sqlite3`, not a new `Ecto.Repo`*: no pooled connection, no + migration ceremony, no change to `LinearCli.Application`'s supervision + tree. Open a connection, ensure the schema (`CREATE TABLE IF NOT + EXISTS`), do the one query, close. `exqlite` is already compiled in as a + transitive dependency of `ecto_sqlite3` (confirmed in `mix.lock`), so this + needs no new dependency. +4. *At most one active profile, enforced by the database*: a partial unique + index (`CREATE UNIQUE INDEX ... WHERE active = 1`), not application-level + bookkeeping or a second "current profile" table. +5. *Scope this phase to `issue create`/`issue list` only*: those are the two + highest-traffic commands and the two that already have a clear + team/project resolution seam to hook into. `project list --team`, + `issue update --project`, etc. get the same treatment as natural, + separate follow-ons once this plumbing exists - not bundled in here. +6. *Explicit flags always win*: the active profile only fills in + `--team`/`--project` when the flag is omitted; passing either explicitly + behaves exactly as it does today, with no profile involved at all. + +== Verified: interactive mode starts nothing, and Oban's own migrations aren't automatic either + +Confirmed by reading code, not assumed: + +* `LinearCli.Application.start_interactive/0` + (`lib/linear_cli/application.ex:51-59`) starts `Supervisor.start_link([], + opts)` - an *empty* children list. No Ecto repo, no Oban, for any + interactive command. Its own comment (`application.ex:15-20`) is explicit + about why: without this gate, every interactive command would open a + database connection and boot Oban's full supervision tree. `start_daemon/0` + (`application.ex:25-36`) is the only path that starts + `LinearCli.ObanRepo.repo()` + `Oban`, gated on `LINEAR_CLI_DAEMON=true`. +* No `Ecto.Migrator.run`/`with_repo` call exists anywhere in `lib/` or + `config/` (checked directly). `priv/sqlite/migrations/` does contain a + real migration (`20260807133654_add_oban_jobs_table.exs`, a one-liner + calling `Oban.Migrations.up/0`), but it's only ever run via the external + `mix ecto.migrate` task - never invoked by application code at boot. That + fits a long-lived daemon with a deploy step ahead of it; it does not fit a + one-shot Burrito binary with no deploy step at all. + +Both together are why this phase does not extend `ObanRepo`/Ecto for +profiles - it would mean either adding a pooled repo to every interactive +invocation's now-empty supervisor (the exact overhead +`start_interactive/0` was written to avoid), or inventing an +auto-migrate-on-boot mechanism this codebase has never needed. A four-column +table with no relations doesn't need Ecto's query DSL or changeset +validation to justify either. + +== Data layer: `LinearCli.Profiles` + +New file `lib/linear_cli/profiles.ex` (peer to `oban_repo.ex` - a data +module, not CLI-specific, so it doesn't live under `cli/`). + +* DB path: new `config :linear_cli, :profiles_db_path` entry in + `config/runtime.exs`, following the exact `case config_env() do :test -> + ... :dev -> ... :prod -> ... end` shape already there for Oban's sqlite + path (`runtime.exs:12-19`). `:prod` -> `Path.join(System.user_home!(), + ".linear_cli/profiles.db")`. `:dev` -> `Path.expand("../profiles_dev.db", + __DIR__)`. `:test` -> `Path.expand("../profiles_test.db", __DIR__)` - a + real file, not `:memory:` like Oban's test config: since every call here + opens *and closes* its own connection, an in-memory database would be + wiped between calls. +* Schema, created idempotently at the top of every connection: ++ +[source,sql] +---- +CREATE TABLE IF NOT EXISTS profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + team TEXT, + project TEXT, + active INTEGER NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS profiles_active_idx ON profiles(active) WHERE active = 1; +---- +* Public API, built on the real `Exqlite.Sqlite3` functions (verified + directly against `deps/exqlite/lib/exqlite/sqlite3.ex`: `open/2`, + `close/1`, `execute/2`, `prepare/2`, `bind/2`, `step/2`, `fetch_all/2`, + `columns/2`, `changes/1`): +** `create(name, attrs)` - `attrs` an optional `:team`/`:project` map/keyword. `INSERT`. +** `list/0` - all profiles, ordered by name. +** `activate(name)` - one transaction: deactivate everything, then activate + the named row; `{:error, :not_found}` if `Sqlite3.changes/1` on the + second statement is `0`. +** `active/0` - the single active profile, or `nil`. +** `delete(name)` - same not-found handling as `activate/1`. +** `default_team/0`, `default_project/0` - thin wrappers around `active/0`; + `nil` with no active profile, including the very first run before the + table has any rows. +* Private `with_db/1` creates the containing directory (mirroring + `ensure_db_ready!/1`, `application.ex:63-69`), opens the connection, + ensures the schema, runs the given function, always closes. + +== CLI surface + +New top-level `profile` command in `LinearCli.CLI.spec/0` (`lib/linear_cli/cli.ex`), +alongside the existing `team:`/`project:` entries, same shape: + +[source,elixir] +---- +profile: [ + name: "profile", + about: "Manage saved team/project profiles", + subcommands: [ + create: [ + name: "create", + about: "Save a new profile", + args: [name: [value_name: "NAME", help: "Profile name", required: true]], + options: [ + team: [short: "-t", long: "--team", help: "Default team for this profile"], + project: [short: "-p", long: "--project", help: "Default project for this profile"] + ] + ], + list: [name: "list", about: "List saved profiles"], + use: [ + name: "use", + about: "Switch to a saved profile", + args: [name: [value_name: "NAME", help: "Profile name", required: true]] + ], + show: [name: "show", about: "Show the active profile"], + delete: [ + name: "delete", + about: "Delete a saved profile", + args: [name: [value_name: "NAME", help: "Profile name", required: true]] + ] + ] +] +---- + +Dispatch clauses next to the existing ones (`cli.ex:161-177`): +`[:profile, :create]`, `[:profile, :list]`, `[:profile, :use]`, +`[:profile, :show]`, `[:profile, :delete]`, each +`run(&Commands.profile_*/1, result, halt)`. New functions in +`lib/linear_cli/cli/commands.ex`: `profile_create/1`, `profile_list/1`, +`profile_use/1`, `profile_show/1`, `profile_delete/1`. `{:error, +:not_found}` becomes a plain `{:error, "No profile named #{name}"}`-style +tuple - the existing `run/3`/`handle_error/3` machinery already turns any +command's `{:error, reason}` into a clean message and non-zero exit, so no +new error-handling plumbing is needed. + +No new command alias - `p` is already `project`'s alias in +`@command_aliases`, and nothing here calls for a short form yet. + +== Wiring the fallback into `issue create`/`issue list` + +* `IssueHelpers.make_da_issue!/1` (`lib/linear_cli/cli/issue_helpers.ex:349-363`) + is the single choke point `issue create` already routes through for both + team and project resolution. Two lines at the top: ++ +[source,elixir] +---- +team_key = opts[:team] || LinearCli.Profiles.default_team() +project_key = opts[:project] || LinearCli.Profiles.default_project() +---- ++ +then use `team_key`/`project_key` in place of `opts[:team]`/`opts[:project]` +in the existing `WhatFor.team_for/1`/`Projects.project_for/2` calls right +below - unchanged otherwise, including the fall-through to interactive +prompting when neither an explicit flag nor a stored default exists. +* `Commands.issue_list/1` (`lib/linear_cli/cli/commands.ex:92-118`) doesn't + route through `make_da_issue!` - it passes `options.team`/`options.project` + straight through. Same fallback, applied directly: + `team_key: options.team || LinearCli.Profiles.default_team()` and + `resolve_project_id(options.project || LinearCli.Profiles.default_project())`. + +== Tests + +* `test/linear_cli/profiles_test.exs` (new): `create/2`, `list/0`, + `activate/1` (activating a second profile deactivates the first; + activating an unknown name returns `{:error, :not_found}`), `active/0`, + `delete/1`, `default_team/0`/`default_project/0` returning `nil` with no + active profile. Not `async: true` - every test shares one sqlite file via + `config :linear_cli, :profiles_db_path`; `setup` deletes that file first + for isolation between tests. +* One targeted test each on `IssueHelpers.make_da_issue!/1` and + `Commands.issue_list/1`, seeding a real active profile via + `Profiles.create/2` + `Profiles.activate/1` against the test db path: + the stored default is used when the flag is omitted, and an explicit flag + still overrides it. + +== Docs + +Short "Default team/project (profiles)" subsection in `Readme.adoc`'s +Commands section, showing `lc profile create`, `lc profile use`, `lc +profile list`, `lc profile show` - commands only, no rationale, per this +repo's established Readme style. + +== Sequencing + +1. `LinearCli.Profiles` (data layer) + its own tests - no CLI wiring yet, + fully testable in isolation. +2. `profile` command (spec, dispatch, `Commands.profile_*` functions) - a + working `lc profile create/list/use/show/delete`, testable end to end, + still with zero effect on `issue create`/`issue list`. +3. Wire the fallback into `IssueHelpers.make_da_issue!/1` and + `Commands.issue_list/1`, plus their targeted tests. +4. `Readme.adoc`. +5. Manual end-to-end verification: build a release/escript, `lc profile + create test --team `, `lc profile use test`, `lc issue + list` with no `--team` and confirm the filter applies, `lc profile show`, + `lc profile delete test`. + +Standard workflow from here: file a GitHub issue for this phase, branch +from it, commit, open the PR - no direct-to-main commits.