From 6a4091c322f5aec92db0fb0f56e32e768832d19c Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 19 Aug 2026 12:01:15 -0400 Subject: [PATCH 1/3] fix(issue-list): scope project prompt to team when team is known When a team key is available (via --team or the active profile), resolve_project_id now looks up projects only within that team instead of fetching every workspace project. This prevents cross-team projects from appearing in the interactive --project prompt and makes project resolution cheaper for focused workflows. Fallback when no team context is present is unchanged: all workspace projects are searched, preserving existing behaviour. Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli/commands.ex | 16 +++++-- .../linear_cli/cli/issue_commands_test.exs | 44 +++++++++++++++++++ .../linear_cli/cli/profile_defaults_test.exs | 14 ++++-- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 4d346e1..f92f729 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -239,7 +239,7 @@ defmodule LinearCli.CLI.Commands do project_source = options.project || unless no_profile, do: Profiles.default_project() - with {:ok, project_id} <- resolve_project_id(project_source) do + with {:ok, project_id} <- resolve_project_id(project_source, team_key) do input = %{ ids: Enum.map(ids, &IssueHelpers.expand_issue_id/1), mine: !flags.no_mine, @@ -257,9 +257,19 @@ defmodule LinearCli.CLI.Commands do end end - defp resolve_project_id(nil), do: {:ok, nil} + defp resolve_project_id(nil, _team_key), do: {:ok, nil} - defp resolve_project_id(search) do + defp resolve_project_id(search, team_key) when is_binary(team_key) do + with {:ok, team} <- Linear.find_team(team_key), + {:ok, projects} <- Linear.projects_by_team(team.id) do + case Projects.project_for(projects, search) do + nil -> {:ok, nil} + project -> {:ok, project.id} + end + end + end + + defp resolve_project_id(search, _team_key) do with {:ok, projects} <- Linear.projects() do case Projects.project_for(projects, search) do nil -> {:ok, nil} diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index 5fd7f0e..c5764cd 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -186,6 +186,50 @@ defmodule LinearCli.CLI.IssueCommandsTest do assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p1"}}}} end + test "--project with --team resolves against team-scoped projects only" do + 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") -> + raise "--project with --team must not query all-workspace projects" + + String.contains?(query, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map()}}) + + String.contains?(query, "projects(first: 100)") -> + Req.Test.json(conn, team_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) + + output = + capture_io(fn -> + assert :ok = + LinearCli.CLI.main([ + "issue", + "list", + "--team", + "ENG", + "--project", + "Manhattan Rollout" + ]) + end) + + assert output =~ "CRY-1" + assert_received {:filter, %{"project" => %{"id" => %{"eq" => "p1"}}}} + end + test "bare issue list applies no project filter and never queries projects at all" do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) diff --git a/app/test/linear_cli/cli/profile_defaults_test.exs b/app/test/linear_cli/cli/profile_defaults_test.exs index 8dfc1a1..f3ffefd 100644 --- a/app/test/linear_cli/cli/profile_defaults_test.exs +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -153,8 +153,11 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map("CRY")}}) + + String.contains?(query, "projects(first: 100)") -> + Req.Test.json(conn, team_projects([project_map("p1", "Manhattan Rollout")])) String.contains?(query, "issues(filter") -> send(test_pid, {:filter, decoded["variables"]["filter"]}) @@ -191,8 +194,11 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do 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, "team(id: $id)") -> + Req.Test.json(conn, %{"data" => %{"team" => team_map("ENG")}}) + + String.contains?(query, "projects(first: 100)") -> + Req.Test.json(conn, team_projects([project_map("p2", "Platform Cleanup")])) String.contains?(query, "issues(filter") -> send(test_pid, {:filter, decoded["variables"]["filter"]}) From 18669bce89725f173bb520d54dd6d5c20d9e68df Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 19 Aug 2026 12:04:54 -0400 Subject: [PATCH 2/3] fix(project): scope project prompt to team for favorite, unfavorite, update project favorite, project unfavorite, and project update now resolve the project against the current team's projects instead of fetching every workspace project. Team is resolved from --team (new option on all three subcommands), the active profile, or an interactive prompt - the same resolution order issue create uses. This prevents cross-team projects from appearing in the interactive project prompt and makes project resolution cheaper for focused workflows. Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli.ex | 7 ++++ app/lib/linear_cli/cli/commands.ex | 23 ++++++++----- .../cli/favorites_commands_test.exs | 24 ++++++++++++++ app/test/linear_cli/cli_test.exs | 32 +++++++++++++------ 4 files changed, 68 insertions(+), 18 deletions(-) diff --git a/app/lib/linear_cli/cli.ex b/app/lib/linear_cli/cli.ex index cadef5a..536b3fc 100644 --- a/app/lib/linear_cli/cli.ex +++ b/app/lib/linear_cli/cli.ex @@ -456,6 +456,9 @@ defmodule LinearCli.CLI do help: "Project name, URL, ID, or search term", required: true ] + ], + options: [ + team: [short: "-t", long: "--team", help: "Scope project search to this team"] ] ], unfavorite: [ @@ -467,6 +470,9 @@ defmodule LinearCli.CLI do help: "Project name, URL, ID, or search term", required: true ] + ], + options: [ + team: [short: "-t", long: "--team", help: "Scope project search to this team"] ] ], update: [ @@ -480,6 +486,7 @@ defmodule LinearCli.CLI do ] ], options: [ + team: [short: "-t", long: "--team", help: "Scope project search to this team"], body: [short: "-b", long: "--body", help: "The update's content (markdown)"], health: [ long: "--health", diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index f92f729..357270f 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -4,7 +4,7 @@ defmodule LinearCli.CLI.Commands do result. Ported from vendor/ruby-linear-cli/lib/linear/commands/**. """ - alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt} + alias LinearCli.CLI.{Display, IssueHelpers, Projects, Prompt, WhatFor} alias LinearCli.{Favorites, Git, Linear, Profiles} @doc "Ported from commands/whoami.rb." @@ -94,8 +94,10 @@ defmodule LinearCli.CLI.Commands do 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(), + def project_favorite(%{args: %{project: search}, options: options}) do + team = WhatFor.team_for(options.team || Profiles.default_team()) + + with {:ok, projects} <- Linear.projects_by_team(team.id), project when not is_nil(project) <- Projects.project_for(projects, search) do Favorites.add("project", project.id) Prompt.ok("Favorited project #{project.name}") @@ -107,8 +109,10 @@ defmodule LinearCli.CLI.Commands do 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(), + def project_unfavorite(%{args: %{project: search}, options: options}) do + team = WhatFor.team_for(options.team || Profiles.default_team()) + + with {:ok, projects} <- Linear.projects_by_team(team.id), project when not is_nil(project) <- Projects.project_for(projects, search) do Favorites.remove("project", project.id) Prompt.ok("Un-favorited project #{project.name}") @@ -137,11 +141,14 @@ defmodule LinearCli.CLI.Commands do New in this port - Ruby has no equivalent. Posts a status update (Linear's own "Project Update" feature - a journal-style status post, not an edit to the project's own fields) via the projectUpdateCreate - mutation. `PROJECT` is resolved the same way issue list's `--project` - is - against every project in the workspace, prompting if ambiguous. + mutation. `PROJECT` is resolved against the active team's projects, + prompting if ambiguous. Team is resolved via `--team`, the active + profile, or an interactive prompt. """ def project_update(%{args: %{project: search}, options: options}) do - with {:ok, projects} <- Linear.projects(), + team = WhatFor.team_for(options.team || Profiles.default_team()) + + with {:ok, projects} <- Linear.projects_by_team(team.id), project when not is_nil(project) <- Projects.project_for(projects, search), {:ok, update} <- Linear.post_project_update(project.id, options.body, %{health: options.health}) do diff --git a/app/test/linear_cli/cli/favorites_commands_test.exs b/app/test/linear_cli/cli/favorites_commands_test.exs index bda2241..1b32d44 100644 --- a/app/test/linear_cli/cli/favorites_commands_test.exs +++ b/app/test/linear_cli/cli/favorites_commands_test.exs @@ -53,6 +53,30 @@ defmodule LinearCli.CLI.FavoritesCommandsTest do } }) + query =~ "team(id: $teamId)" -> + Req.Test.json(conn, %{ + "data" => %{ + "team" => %{ + "projects" => %{ + "nodes" => [ + %{ + "id" => "p1", + "name" => "Manhattan", + "slugId" => "abc", + "url" => "https://linear.app/x/project/manhattan-abc" + }, + %{ + "id" => "p2", + "name" => "Platform Cleanup", + "slugId" => "def", + "url" => "https://linear.app/x/project/platform-cleanup-def" + } + ] + } + } + } + }) + query =~ "projects(" -> Req.Test.json(conn, %{ "data" => %{ diff --git a/app/test/linear_cli/cli_test.exs b/app/test/linear_cli/cli_test.exs index 36e7c43..b0cb976 100644 --- a/app/test/linear_cli/cli_test.exs +++ b/app/test/linear_cli/cli_test.exs @@ -115,22 +115,34 @@ defmodule LinearCli.CLITest do query = decoded["query"] cond do - String.contains?(query, "projects(first: $first") -> + String.contains?(query, "viewer") -> Req.Test.json(conn, %{ "data" => %{ - "projects" => %{ - "edges" => [ - %{ - "node" => %{ + "viewer" => %{ + "id" => "u1", + "name" => "Ada", + "email" => "ada@example.com", + "teams" => %{ + "nodes" => [%{"id" => "t1", "key" => "ENG", "name" => "Engineering"}] + } + } + } + }) + + String.contains?(query, "team(id: $teamId)") -> + Req.Test.json(conn, %{ + "data" => %{ + "team" => %{ + "projects" => %{ + "nodes" => [ + %{ "id" => "p1", "name" => "Manhattan", "slugId" => "abc", "url" => "https://linear.app/x/project/manhattan-abc" - }, - "cursor" => "p1" - } - ], - "pageInfo" => %{"hasNextPage" => false} + } + ] + } } } }) From 049b2fa99a4178dd27eb1f3b59ce0468b2c0960f Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Wed, 19 Aug 2026 16:00:07 -0400 Subject: [PATCH 3/3] docs(project): update stale docstrings to reflect team-scoped resolution project_favorite/1 and issue_list/1 docstrings still described workspace-wide project resolution after the team-scoped implementation was added. Co-Authored-By: Claude Sonnet 4.6 --- app/lib/linear_cli/cli/commands.ex | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index 357270f..a24bfe6 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -89,10 +89,10 @@ defmodule LinearCli.CLI.Commands do @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). + (`LinearCli.Favorites`), resolved against the active team's projects, + prompting if ambiguous. Team is resolved via `--team`, the active + profile, or an interactive prompt. Once any project is favorited, + `project list` defaults to showing just favorites (`--all` overrides). """ def project_favorite(%{args: %{project: search}, options: options}) do team = WhatFor.team_for(options.team || Profiles.default_team()) @@ -230,14 +230,15 @@ defmodule LinearCli.CLI.Commands do @doc """ Ported from commands/issue/list.rb + operations/issue/list.rb. - `--project`/`-p` is resolved the same way Ruby's `CLI::Projects#project_for` - 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 (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. + `--project`/`-p` resolution is team-scoped when `--team` is given (or + the active profile supplies a team) - it searches that team's projects via + `projects_by_team`. Without a team context it falls back to all workspace + projects (`Project.all`). Prompts interactively when the search is + ambiguous or omitted-but-requested (`-p -`). Only resolved at all when + `--project` was 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 no_profile = Map.get(flags, :no_profile, false)