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 4d346e1..a24bfe6 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." @@ -89,13 +89,15 @@ 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}}) 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 @@ -223,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) @@ -239,7 +247,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 +265,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, 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) do + 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/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/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"]}) 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} + } + ] + } } } })