diff --git a/app/lib/linear_cli/cli/commands.ex b/app/lib/linear_cli/cli/commands.ex index a24bfe6..9c64efb 100644 --- a/app/lib/linear_cli/cli/commands.ex +++ b/app/lib/linear_cli/cli/commands.ex @@ -97,7 +97,7 @@ defmodule LinearCli.CLI.Commands do 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), + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), project when not is_nil(project) <- Projects.project_for(projects, search) do Favorites.add("project", project.id) Prompt.ok("Favorited project #{project.name}") @@ -112,7 +112,7 @@ defmodule LinearCli.CLI.Commands do 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), + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), project when not is_nil(project) <- Projects.project_for(projects, search) do Favorites.remove("project", project.id) Prompt.ok("Un-favorited project #{project.name}") @@ -148,7 +148,7 @@ defmodule LinearCli.CLI.Commands do def project_update(%{args: %{project: search}, options: options}) do team = WhatFor.team_for(options.team || Profiles.default_team()) - with {:ok, projects} <- Linear.projects_by_team(team.id), + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}), 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 @@ -269,7 +269,7 @@ defmodule LinearCli.CLI.Commands 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 + {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}) do case Projects.project_for(projects, search) do nil -> {:ok, nil} project -> {:ok, project.id} diff --git a/app/lib/linear_cli/cli/issue_helpers.ex b/app/lib/linear_cli/cli/issue_helpers.ex index b560de8..7909ca0 100644 --- a/app/lib/linear_cli/cli/issue_helpers.ex +++ b/app/lib/linear_cli/cli/issue_helpers.ex @@ -287,7 +287,8 @@ defmodule LinearCli.CLI.IssueHelpers do @spec attach_project(%Linear.Issue{}, String.t() | nil) :: {:ok, %Linear.Issue{}} | {:error, term()} def attach_project(issue, project_search) do - with {:ok, projects} <- Linear.projects_by_team(issue.team.id) do + with {:ok, projects} <- + Linear.projects_by_team(issue.team.id, %{search: project_search}) do project = Projects.project_for(projects, project_search) case Linear.attach_issue_to_project(issue, project.id) do @@ -395,9 +396,10 @@ defmodule LinearCli.CLI.IssueHelpers do description = WhatFor.description_for(opts[:description]) team = WhatFor.team_for(opts[:team] || Profiles.default_team()) labels = WhatFor.labels_for(team, opts[:labels]) + project_search = opts[:project] || Profiles.default_project() - with {:ok, projects} <- Linear.projects_by_team(team.id) do - project = Projects.project_for(projects, opts[:project] || Profiles.default_project()) + with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: project_search}) do + project = Projects.project_for(projects, project_search) label_ids = Enum.map(labels, & &1.id) params = maybe_put_project_id(%{label_ids: label_ids}, project) diff --git a/app/lib/linear_cli/cli/projects.ex b/app/lib/linear_cli/cli/projects.ex index 18e37dc..df9c06e 100644 --- a/app/lib/linear_cli/cli/projects.ex +++ b/app/lib/linear_cli/cli/projects.ex @@ -26,8 +26,7 @@ defmodule LinearCli.CLI.Projects do * `search` given but no project scores positively -> delegates to `ask_for_projects/2` (which warns "No project found matching \#{search}." and then prompts across *all* `projects`) - * exactly the positively-scoring candidates score `100` in aggregate, - i.e. the lowest-scoring positive match is itself a `100` (an exact + * any positively-scoring candidate scores `100` (an exact id/url/slug/name match) -> that project, no prompt * otherwise -> `LinearCli.CLI.Prompt.select/2` over the positively scoring candidates (lowest score first, per `project_scores/2`'s @@ -44,12 +43,14 @@ defmodule LinearCli.CLI.Projects do [] -> ask_for_projects(projects, search) - [first | _] = possibles -> - if Project.match_score?(first, search) == 100 do - first - else - selections = possibles ++ (projects -- possibles) - Prompt.select("Project:", Enum.map(selections, &{&1.name, &1})) + possibles -> + case Enum.find(possibles, &(Project.match_score?(&1, search) == 100)) do + nil -> + selections = possibles ++ (projects -- possibles) + Prompt.select("Project:", Enum.map(selections, &{&1.name, &1})) + + exact -> + exact end end end diff --git a/app/lib/linear_cli/linear/project.ex b/app/lib/linear_cli/linear/project.ex index 7249b30..47985e5 100644 --- a/app/lib/linear_cli/linear/project.ex +++ b/app/lib/linear_cli/linear/project.ex @@ -16,6 +16,7 @@ defmodule LinearCli.Linear.Project do read :by_team do argument :team_id, :string, allow_nil?: false + argument :search, :string manual LinearCli.Linear.Project.Read.ByTeam end @@ -111,12 +112,24 @@ defmodule LinearCli.Linear.Project do def matches_attributes?(%__MODULE__{} = project, string, attrs) do Enum.any?(attrs, fn attr -> case Map.get(project, attr) do - value when is_binary(value) -> String.downcase(value) == String.downcase(string) - _ -> false + value when is_binary(value) -> + normalize_match_value(attr, value) == normalize_match_value(attr, string) + + _ -> + false end end) end + defp normalize_match_value(:url, value) do + value + |> String.trim_trailing("/") + |> String.trim_trailing("/issues") + |> String.downcase() + end + + defp normalize_match_value(_attr, value), do: String.downcase(value) + defp exact_name_or_slug_match?(project, string) do downed = String.downcase(string) slugified = downed |> String.split() |> Enum.join("-") @@ -166,11 +179,24 @@ defmodule LinearCli.Linear.Project.Read.ByTeam do alias LinearCli.Api alias LinearCli.Linear.Project - # Ruby's Team#projects fetches a single page of 100, no cursor loop. + @uuid ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + @slugged_reference ~r/^(.*)-([[:alnum:]]{12})$/ + @document """ - query($teamId: String!) { + query($teamId: String!, $after: String) { team(id: $teamId) { - projects(first: 100) { + projects(first: 100, after: $after) { + nodes { #{Project.base_fields()} } + pageInfo { hasNextPage endCursor } + } + } + } + """ + + @search_document """ + query($teamId: String!, $filter: ProjectFilter!) { + team(id: $teamId) { + projects(first: 100, filter: $filter) { nodes { #{Project.base_fields()} } } } @@ -180,11 +206,83 @@ defmodule LinearCli.Linear.Project.Read.ByTeam do def read(query, _ecto_query, _opts, _context) do team_id = query.arguments.team_id + case Map.get(query.arguments, :search) do + search when is_binary(search) and search not in ["", "-"] -> + search(team_id, search) + + _ -> + all(team_id) + end + end + + defp search(team_id, search) do + variables = %{"teamId" => team_id, "filter" => project_filter(search)} + + with {:ok, projects} <- fetch(@search_document, variables) do + if projects == [], do: all(team_id), else: {:ok, projects} + end + end + + defp all(team_id), do: page(team_id, nil, []) + + defp page(team_id, after_cursor, acc) do + variables = + if after_cursor, + do: %{"teamId" => team_id, "after" => after_cursor}, + else: %{"teamId" => team_id} + + with {:ok, %{"team" => %{"projects" => projects}}} <- Api.call(@document, variables) do + acc = acc ++ Enum.map(projects["nodes"] || [], &Project.from_map/1) + + case projects["pageInfo"] do + %{"hasNextPage" => true, "endCursor" => cursor} when is_binary(cursor) -> + page(team_id, cursor, acc) + + _ -> + {:ok, acc} + end + end + end + + defp fetch(document, variables) do with {:ok, %{"team" => %{"projects" => %{"nodes" => nodes}}}} <- - Api.call(@document, %{"teamId" => team_id}) do + Api.call(document, variables) do {:ok, Enum.map(nodes, &Project.from_map/1)} end end + + defp project_filter(search) do + reference = project_reference(search) + {name, slug_id} = reference_terms(reference) + + [ + project_id_filter(search), + %{"name" => %{"containsIgnoreCase" => search}}, + %{"name" => %{"containsIgnoreCase" => name}}, + %{"slugId" => %{"eqIgnoreCase" => slug_id}} + ] + |> Enum.reject(&is_nil/1) + |> Enum.uniq() + |> then(&%{"or" => &1}) + end + + defp project_reference(search) do + case Regex.run(~r{/project/([^/]+)}, search, capture: :all_but_first) do + [reference] -> reference + _ -> search + end + end + + defp reference_terms(reference) do + case Regex.run(@slugged_reference, reference, capture: :all_but_first) do + [slug, slug_id] -> {String.replace(slug, "-", " "), slug_id} + _ -> {String.replace(reference, "-", " "), reference} + end + end + + defp project_id_filter(search) do + if Regex.match?(@uuid, search), do: %{"id" => %{"eq" => search}} + end end defmodule LinearCli.Linear.Project.Read.ByName do diff --git a/app/test/linear_cli/cli/issue_commands_test.exs b/app/test/linear_cli/cli/issue_commands_test.exs index c5764cd..b89a884 100644 --- a/app/test/linear_cli/cli/issue_commands_test.exs +++ b/app/test/linear_cli/cli/issue_commands_test.exs @@ -201,8 +201,18 @@ defmodule LinearCli.CLI.IssueCommandsTest do 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, "projects(first: 100, filter: $filter)") -> + filters = decoded["variables"]["filter"]["or"] + + assert %{"name" => %{"containsIgnoreCase" => "Wallet Service Extraction"}} in filters + + Req.Test.json( + conn, + team_projects([ + project_map("p2", "Wallet Service Extraction for Humans"), + project_map("p1", "Wallet Service Extraction") + ]) + ) String.contains?(query, "issues(filter") -> send(test_pid, {:filter, decoded["variables"]["filter"]}) @@ -222,7 +232,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do "--team", "ENG", "--project", - "Manhattan Rollout" + "Wallet Service Extraction" ]) end) @@ -376,7 +386,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do stub_responses([ {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, {"issueLabels", label_response(["urgent"])}, - {"projects(first: 100)", team_projects([project_map("p1", "Manhattan Rollout")])}, + {"projects(first: 100", team_projects([project_map("p1", "Manhattan Rollout")])}, {"issueCreate", %{ "data" => %{ @@ -435,7 +445,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do stub_responses([ {"team(id: $id)", %{"data" => %{"team" => team_map()}}}, {"issueLabels", label_response(["urgent"])}, - {"projects(first: 100)", team_projects([project_map("p1", "Manhattan Rollout")])}, + {"projects(first: 100", team_projects([project_map("p1", "Manhattan Rollout")])}, {"issueCreate", %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}}, {"issue(id: $id)", %{"data" => %{"issue" => created_issue}}} ]) diff --git a/app/test/linear_cli/cli/issue_helpers_test.exs b/app/test/linear_cli/cli/issue_helpers_test.exs index 9be1c11..24d7316 100644 --- a/app/test/linear_cli/cli/issue_helpers_test.exs +++ b/app/test/linear_cli/cli/issue_helpers_test.exs @@ -25,7 +25,7 @@ defmodule LinearCli.CLI.IssueHelpersTest do # Dispatches to one of `pairs` ({substring, response_map}) based on which # substring appears in the outgoing GraphQL document - every document in # this codebase has a distinguishing operation name/field - # (`commentCreate`, `issueUpdate`, `states {`, `projects(first: 100)`, + # (`commentCreate`, `issueUpdate`, `states {`, `projects(first: 100`, # `issueCreate`, `viewer`, `issue(id: $id)`), so one stub per test can # drive an entire multi-call flow. defp stub_responses(pairs) do @@ -286,7 +286,7 @@ defmodule LinearCli.CLI.IssueHelpersTest do describe "attach_project/2 (Ruby: CLI::Issue#attach_project)" do test "resolves the project by name against the team's projects and attaches it" do stub_responses([ - {"projects(first: 100)", + {"projects(first: 100", team_projects([ %{ "id" => "p1", @@ -361,7 +361,7 @@ defmodule LinearCli.CLI.IssueHelpersTest do test "with :project, resolves and attaches" do stub_responses([ - {"projects(first: 100)", + {"projects(first: 100", team_projects([ %{ "id" => "p1", @@ -460,7 +460,7 @@ defmodule LinearCli.CLI.IssueHelpersTest do } } }}, - {"projects(first: 100)", + {"projects(first: 100", team_projects([ %{ "id" => "p1", diff --git a/app/test/linear_cli/cli/profile_defaults_test.exs b/app/test/linear_cli/cli/profile_defaults_test.exs index f3ffefd..67507da 100644 --- a/app/test/linear_cli/cli/profile_defaults_test.exs +++ b/app/test/linear_cli/cli/profile_defaults_test.exs @@ -156,7 +156,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do String.contains?(query, "team(id: $id)") -> Req.Test.json(conn, %{"data" => %{"team" => team_map("CRY")}}) - String.contains?(query, "projects(first: 100)") -> + String.contains?(query, "projects(first: 100") -> Req.Test.json(conn, team_projects([project_map("p1", "Manhattan Rollout")])) String.contains?(query, "issues(filter") -> @@ -197,7 +197,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do String.contains?(query, "team(id: $id)") -> Req.Test.json(conn, %{"data" => %{"team" => team_map("ENG")}}) - String.contains?(query, "projects(first: 100)") -> + String.contains?(query, "projects(first: 100") -> Req.Test.json(conn, team_projects([project_map("p2", "Platform Cleanup")])) String.contains?(query, "issues(filter") -> @@ -544,7 +544,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do String.contains?(query, "issueLabels") -> Req.Test.json(conn, label_response(["urgent"])) - String.contains?(query, "projects(first: 100)") -> + String.contains?(query, "projects(first: 100") -> Req.Test.json(conn, team_projects([project_map("p1", "Manhattan Rollout")])) String.contains?(query, "issueCreate") -> @@ -593,7 +593,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do String.contains?(query, "issueLabels") -> Req.Test.json(conn, label_response(["urgent"])) - String.contains?(query, "projects(first: 100)") -> + String.contains?(query, "projects(first: 100") -> Req.Test.json(conn, team_projects([])) String.contains?(query, "issueCreate") -> diff --git a/app/test/linear_cli/cli/projects_test.exs b/app/test/linear_cli/cli/projects_test.exs index 47910d6..aa03d97 100644 --- a/app/test/linear_cli/cli/projects_test.exs +++ b/app/test/linear_cli/cli/projects_test.exs @@ -78,6 +78,13 @@ defmodule LinearCli.CLI.ProjectsTest do assert Projects.project_for([manhattan, brooklyn], "Manhattan") == manhattan end + test "an exact name wins over a weaker substring match" do + exact = project(id: "1", name: "Wallet Service Extraction") + partial = project(id: "2", name: "Wallet Service Extraction for Humans") + + assert Projects.project_for([partial, exact], "Wallet Service Extraction") == exact + end + test "an exact match by id wins outright even among several candidates" do exact = project(id: "abc-123", name: "Something Else") other = project(id: "other", name: "Something Else Entirely") diff --git a/app/test/linear_cli/cli_test.exs b/app/test/linear_cli/cli_test.exs index b0cb976..2ede2ef 100644 --- a/app/test/linear_cli/cli_test.exs +++ b/app/test/linear_cli/cli_test.exs @@ -413,7 +413,8 @@ defmodule LinearCli.CLITest do "data" => %{ "team" => %{ "projects" => %{ - "nodes" => [%{"id" => "p2", "name" => "Roadmap", "url" => "https://x/p2"}] + "nodes" => [%{"id" => "p2", "name" => "Roadmap", "url" => "https://x/p2"}], + "pageInfo" => %{"hasNextPage" => false} } } } diff --git a/app/test/linear_cli/linear/project_test.exs b/app/test/linear_cli/linear/project_test.exs index 34d875f..2daa617 100644 --- a/app/test/linear_cli/linear/project_test.exs +++ b/app/test/linear_cli/linear/project_test.exs @@ -30,6 +30,33 @@ defmodule LinearCli.Linear.ProjectTest do assert {:ok, [%Linear.Project{id: "p1", name: "Manhattan"}]} = Linear.projects() end + test "projects_by_team/1 paginates only the selected team's projects" do + Req.Test.stub(LinearCli.Api, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + %{"variables" => variables} = Jason.decode!(body) + + {projects, page_info} = + case variables["after"] do + nil -> + {[%{"id" => "p1", "name" => "First"}], + %{"hasNextPage" => true, "endCursor" => "page-1"}} + + "page-1" -> + {[%{"id" => "p2", "name" => "Second"}], + %{"hasNextPage" => false, "endCursor" => "page-2"}} + end + + Req.Test.json(conn, %{ + "data" => %{ + "team" => %{"projects" => %{"nodes" => projects, "pageInfo" => page_info}} + } + }) + end) + + assert {:ok, projects} = Linear.projects_by_team("team-1") + assert Enum.map(projects, & &1.id) == ["p1", "p2"] + end + test "my_projects/0 flat-maps each of the viewer's teams' projects (Ruby: Project.mine)" do Req.Test.stub(LinearCli.Api, fn conn -> {:ok, body, conn} = Plug.Conn.read_body(conn) @@ -189,6 +216,10 @@ defmodule LinearCli.Linear.ProjectTest do assert Linear.Project.match_score?(project, String.upcase(project.url)) == 100 end + test "scores 100 when a Linear project URL ends in /issues", %{project: project} do + assert Linear.Project.match_score?(project, project.url <> "/issues") == 100 + end + test "scores 100 when the slugified search term equals the slug", %{project: project} do assert Linear.Project.match_score?(project, "manhattan") == 100 end diff --git a/documents/ash-domain-erd.adoc b/documents/ash-domain-erd.adoc index 6294102..1dc3f01 100644 --- a/documents/ash-domain-erd.adoc +++ b/documents/ash-domain-erd.adoc @@ -291,7 +291,7 @@ manual-implementation module, and the Linear GraphQL operation it calls. | `:by_team` | read | `Linear.Project.Read.ByTeam` -| `team(id: $teamId) { projects(first: 100) { nodes { ... } } }` +| `team(id: $teamId) { projects(first:, after:, filter:) { ... } }` — paginates the selected team's projects, or applies a server-side name/slug/id filter when `search` is supplied | `Project` | `create_project`