Skip to content

Commit 8bf19cd

Browse files
committed
fix: ensure team scoping wraps sub filters
1 parent 01af383 commit 8bf19cd

10 files changed

Lines changed: 181 additions & 35 deletions

File tree

app/lib/linear_cli/cli/commands.ex

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ defmodule LinearCli.CLI.Commands do
9797
def project_favorite(%{args: %{project: search}, options: options}) do
9898
team = WhatFor.team_for(options.team || Profiles.default_team())
9999

100-
with {:ok, projects} <- Linear.projects_by_team(team.id),
100+
with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}),
101101
project when not is_nil(project) <- Projects.project_for(projects, search) do
102102
Favorites.add("project", project.id)
103103
Prompt.ok("Favorited project #{project.name}")
@@ -112,7 +112,7 @@ defmodule LinearCli.CLI.Commands do
112112
def project_unfavorite(%{args: %{project: search}, options: options}) do
113113
team = WhatFor.team_for(options.team || Profiles.default_team())
114114

115-
with {:ok, projects} <- Linear.projects_by_team(team.id),
115+
with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}),
116116
project when not is_nil(project) <- Projects.project_for(projects, search) do
117117
Favorites.remove("project", project.id)
118118
Prompt.ok("Un-favorited project #{project.name}")
@@ -148,7 +148,7 @@ defmodule LinearCli.CLI.Commands do
148148
def project_update(%{args: %{project: search}, options: options}) do
149149
team = WhatFor.team_for(options.team || Profiles.default_team())
150150

151-
with {:ok, projects} <- Linear.projects_by_team(team.id),
151+
with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}),
152152
project when not is_nil(project) <- Projects.project_for(projects, search),
153153
{:ok, update} <-
154154
Linear.post_project_update(project.id, options.body, %{health: options.health}) do
@@ -269,7 +269,7 @@ defmodule LinearCli.CLI.Commands do
269269

270270
defp resolve_project_id(search, team_key) when is_binary(team_key) do
271271
with {:ok, team} <- Linear.find_team(team_key),
272-
{:ok, projects} <- Linear.projects_by_team(team.id) do
272+
{:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}) do
273273
case Projects.project_for(projects, search) do
274274
nil -> {:ok, nil}
275275
project -> {:ok, project.id}

app/lib/linear_cli/cli/issue_helpers.ex

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,8 @@ defmodule LinearCli.CLI.IssueHelpers do
287287
@spec attach_project(%Linear.Issue{}, String.t() | nil) ::
288288
{:ok, %Linear.Issue{}} | {:error, term()}
289289
def attach_project(issue, project_search) do
290-
with {:ok, projects} <- Linear.projects_by_team(issue.team.id) do
290+
with {:ok, projects} <-
291+
Linear.projects_by_team(issue.team.id, %{search: project_search}) do
291292
project = Projects.project_for(projects, project_search)
292293

293294
case Linear.attach_issue_to_project(issue, project.id) do
@@ -395,9 +396,10 @@ defmodule LinearCli.CLI.IssueHelpers do
395396
description = WhatFor.description_for(opts[:description])
396397
team = WhatFor.team_for(opts[:team] || Profiles.default_team())
397398
labels = WhatFor.labels_for(team, opts[:labels])
399+
project_search = opts[:project] || Profiles.default_project()
398400

399-
with {:ok, projects} <- Linear.projects_by_team(team.id) do
400-
project = Projects.project_for(projects, opts[:project] || Profiles.default_project())
401+
with {:ok, projects} <- Linear.projects_by_team(team.id, %{search: project_search}) do
402+
project = Projects.project_for(projects, project_search)
401403
label_ids = Enum.map(labels, & &1.id)
402404
params = maybe_put_project_id(%{label_ids: label_ids}, project)
403405

app/lib/linear_cli/cli/projects.ex

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ defmodule LinearCli.CLI.Projects do
2626
* `search` given but no project scores positively -> delegates to
2727
`ask_for_projects/2` (which warns "No project found matching
2828
\#{search}." and then prompts across *all* `projects`)
29-
* exactly the positively-scoring candidates score `100` in aggregate,
30-
i.e. the lowest-scoring positive match is itself a `100` (an exact
29+
* any positively-scoring candidate scores `100` (an exact
3130
id/url/slug/name match) -> that project, no prompt
3231
* otherwise -> `LinearCli.CLI.Prompt.select/2` over the positively
3332
scoring candidates (lowest score first, per `project_scores/2`'s
@@ -44,12 +43,14 @@ defmodule LinearCli.CLI.Projects do
4443
[] ->
4544
ask_for_projects(projects, search)
4645

47-
[first | _] = possibles ->
48-
if Project.match_score?(first, search) == 100 do
49-
first
50-
else
51-
selections = possibles ++ (projects -- possibles)
52-
Prompt.select("Project:", Enum.map(selections, &{&1.name, &1}))
46+
possibles ->
47+
case Enum.find(possibles, &(Project.match_score?(&1, search) == 100)) do
48+
nil ->
49+
selections = possibles ++ (projects -- possibles)
50+
Prompt.select("Project:", Enum.map(selections, &{&1.name, &1}))
51+
52+
exact ->
53+
exact
5354
end
5455
end
5556
end

app/lib/linear_cli/linear/project.ex

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ defmodule LinearCli.Linear.Project do
1616

1717
read :by_team do
1818
argument :team_id, :string, allow_nil?: false
19+
argument :search, :string
1920
manual LinearCli.Linear.Project.Read.ByTeam
2021
end
2122

@@ -111,12 +112,24 @@ defmodule LinearCli.Linear.Project do
111112
def matches_attributes?(%__MODULE__{} = project, string, attrs) do
112113
Enum.any?(attrs, fn attr ->
113114
case Map.get(project, attr) do
114-
value when is_binary(value) -> String.downcase(value) == String.downcase(string)
115-
_ -> false
115+
value when is_binary(value) ->
116+
normalize_match_value(attr, value) == normalize_match_value(attr, string)
117+
118+
_ ->
119+
false
116120
end
117121
end)
118122
end
119123

124+
defp normalize_match_value(:url, value) do
125+
value
126+
|> String.trim_trailing("/")
127+
|> String.trim_trailing("/issues")
128+
|> String.downcase()
129+
end
130+
131+
defp normalize_match_value(_attr, value), do: String.downcase(value)
132+
120133
defp exact_name_or_slug_match?(project, string) do
121134
downed = String.downcase(string)
122135
slugified = downed |> String.split() |> Enum.join("-")
@@ -166,11 +179,24 @@ defmodule LinearCli.Linear.Project.Read.ByTeam do
166179
alias LinearCli.Api
167180
alias LinearCli.Linear.Project
168181

169-
# Ruby's Team#projects fetches a single page of 100, no cursor loop.
182+
@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
183+
@slugged_reference ~r/^(.*)-([[:alnum:]]{12})$/
184+
170185
@document """
171-
query($teamId: String!) {
186+
query($teamId: String!, $after: String) {
172187
team(id: $teamId) {
173-
projects(first: 100) {
188+
projects(first: 100, after: $after) {
189+
nodes { #{Project.base_fields()} }
190+
pageInfo { hasNextPage endCursor }
191+
}
192+
}
193+
}
194+
"""
195+
196+
@search_document """
197+
query($teamId: String!, $filter: ProjectFilter!) {
198+
team(id: $teamId) {
199+
projects(first: 100, filter: $filter) {
174200
nodes { #{Project.base_fields()} }
175201
}
176202
}
@@ -180,11 +206,80 @@ defmodule LinearCli.Linear.Project.Read.ByTeam do
180206
def read(query, _ecto_query, _opts, _context) do
181207
team_id = query.arguments.team_id
182208

209+
case Map.get(query.arguments, :search) do
210+
search when is_binary(search) and search not in ["", "-"] ->
211+
search(team_id, search)
212+
213+
_ ->
214+
all(team_id)
215+
end
216+
end
217+
218+
defp search(team_id, search) do
219+
variables = %{"teamId" => team_id, "filter" => project_filter(search)}
220+
221+
with {:ok, projects} <- fetch(@search_document, variables) do
222+
if projects == [], do: all(team_id), else: {:ok, projects}
223+
end
224+
end
225+
226+
defp all(team_id), do: page(team_id, nil, [])
227+
228+
defp page(team_id, after_cursor, acc) do
229+
variables = %{"teamId" => team_id, "after" => after_cursor}
230+
231+
with {:ok, %{"team" => %{"projects" => projects}}} <- Api.call(@document, variables) do
232+
acc = acc ++ Enum.map(projects["nodes"] || [], &Project.from_map/1)
233+
234+
case projects["pageInfo"] do
235+
%{"hasNextPage" => true, "endCursor" => cursor} when is_binary(cursor) ->
236+
page(team_id, cursor, acc)
237+
238+
_ ->
239+
{:ok, acc}
240+
end
241+
end
242+
end
243+
244+
defp fetch(document, variables) do
183245
with {:ok, %{"team" => %{"projects" => %{"nodes" => nodes}}}} <-
184-
Api.call(@document, %{"teamId" => team_id}) do
246+
Api.call(document, variables) do
185247
{:ok, Enum.map(nodes, &Project.from_map/1)}
186248
end
187249
end
250+
251+
defp project_filter(search) do
252+
reference = project_reference(search)
253+
{name, slug_id} = reference_terms(reference)
254+
255+
[
256+
project_id_filter(search),
257+
%{"name" => %{"containsIgnoreCase" => search}},
258+
%{"name" => %{"containsIgnoreCase" => name}},
259+
%{"slugId" => %{"eqIgnoreCase" => slug_id}}
260+
]
261+
|> Enum.reject(&is_nil/1)
262+
|> Enum.uniq()
263+
|> then(&%{"or" => &1})
264+
end
265+
266+
defp project_reference(search) do
267+
case Regex.run(~r{/project/([^/]+)}, search, capture: :all_but_first) do
268+
[reference] -> reference
269+
_ -> search
270+
end
271+
end
272+
273+
defp reference_terms(reference) do
274+
case Regex.run(@slugged_reference, reference, capture: :all_but_first) do
275+
[slug, slug_id] -> {String.replace(slug, "-", " "), slug_id}
276+
_ -> {String.replace(reference, "-", " "), reference}
277+
end
278+
end
279+
280+
defp project_id_filter(search) do
281+
if Regex.match?(@uuid, search), do: %{"id" => %{"eq" => search}}
282+
end
188283
end
189284

190285
defmodule LinearCli.Linear.Project.Read.ByName do

app/test/linear_cli/cli/issue_commands_test.exs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,18 @@ defmodule LinearCli.CLI.IssueCommandsTest do
201201
String.contains?(query, "team(id: $id)") ->
202202
Req.Test.json(conn, %{"data" => %{"team" => team_map()}})
203203

204-
String.contains?(query, "projects(first: 100)") ->
205-
Req.Test.json(conn, team_projects([project_map("p1", "Manhattan Rollout")]))
204+
String.contains?(query, "projects(first: 100, filter: $filter)") ->
205+
filters = decoded["variables"]["filter"]["or"]
206+
207+
assert %{"name" => %{"containsIgnoreCase" => "Wallet Service Extraction"}} in filters
208+
209+
Req.Test.json(
210+
conn,
211+
team_projects([
212+
project_map("p2", "Wallet Service Extraction for Humans"),
213+
project_map("p1", "Wallet Service Extraction")
214+
])
215+
)
206216

207217
String.contains?(query, "issues(filter") ->
208218
send(test_pid, {:filter, decoded["variables"]["filter"]})
@@ -222,7 +232,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
222232
"--team",
223233
"ENG",
224234
"--project",
225-
"Manhattan Rollout"
235+
"Wallet Service Extraction"
226236
])
227237
end)
228238

@@ -376,7 +386,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
376386
stub_responses([
377387
{"team(id: $id)", %{"data" => %{"team" => team_map()}}},
378388
{"issueLabels", label_response(["urgent"])},
379-
{"projects(first: 100)", team_projects([project_map("p1", "Manhattan Rollout")])},
389+
{"projects(first: 100", team_projects([project_map("p1", "Manhattan Rollout")])},
380390
{"issueCreate",
381391
%{
382392
"data" => %{
@@ -435,7 +445,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
435445
stub_responses([
436446
{"team(id: $id)", %{"data" => %{"team" => team_map()}}},
437447
{"issueLabels", label_response(["urgent"])},
438-
{"projects(first: 100)", team_projects([project_map("p1", "Manhattan Rollout")])},
448+
{"projects(first: 100", team_projects([project_map("p1", "Manhattan Rollout")])},
439449
{"issueCreate", %{"data" => %{"issueCreate" => %{"issue" => created_issue}}}},
440450
{"issue(id: $id)", %{"data" => %{"issue" => created_issue}}}
441451
])

app/test/linear_cli/cli/issue_helpers_test.exs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ defmodule LinearCli.CLI.IssueHelpersTest do
2525
# Dispatches to one of `pairs` ({substring, response_map}) based on which
2626
# substring appears in the outgoing GraphQL document - every document in
2727
# this codebase has a distinguishing operation name/field
28-
# (`commentCreate`, `issueUpdate`, `states {`, `projects(first: 100)`,
28+
# (`commentCreate`, `issueUpdate`, `states {`, `projects(first: 100`,
2929
# `issueCreate`, `viewer`, `issue(id: $id)`), so one stub per test can
3030
# drive an entire multi-call flow.
3131
defp stub_responses(pairs) do
@@ -286,7 +286,7 @@ defmodule LinearCli.CLI.IssueHelpersTest do
286286
describe "attach_project/2 (Ruby: CLI::Issue#attach_project)" do
287287
test "resolves the project by name against the team's projects and attaches it" do
288288
stub_responses([
289-
{"projects(first: 100)",
289+
{"projects(first: 100",
290290
team_projects([
291291
%{
292292
"id" => "p1",
@@ -361,7 +361,7 @@ defmodule LinearCli.CLI.IssueHelpersTest do
361361

362362
test "with :project, resolves and attaches" do
363363
stub_responses([
364-
{"projects(first: 100)",
364+
{"projects(first: 100",
365365
team_projects([
366366
%{
367367
"id" => "p1",
@@ -460,7 +460,7 @@ defmodule LinearCli.CLI.IssueHelpersTest do
460460
}
461461
}
462462
}},
463-
{"projects(first: 100)",
463+
{"projects(first: 100",
464464
team_projects([
465465
%{
466466
"id" => "p1",

app/test/linear_cli/cli/profile_defaults_test.exs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do
156156
String.contains?(query, "team(id: $id)") ->
157157
Req.Test.json(conn, %{"data" => %{"team" => team_map("CRY")}})
158158

159-
String.contains?(query, "projects(first: 100)") ->
159+
String.contains?(query, "projects(first: 100") ->
160160
Req.Test.json(conn, team_projects([project_map("p1", "Manhattan Rollout")]))
161161

162162
String.contains?(query, "issues(filter") ->
@@ -197,7 +197,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do
197197
String.contains?(query, "team(id: $id)") ->
198198
Req.Test.json(conn, %{"data" => %{"team" => team_map("ENG")}})
199199

200-
String.contains?(query, "projects(first: 100)") ->
200+
String.contains?(query, "projects(first: 100") ->
201201
Req.Test.json(conn, team_projects([project_map("p2", "Platform Cleanup")]))
202202

203203
String.contains?(query, "issues(filter") ->
@@ -544,7 +544,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do
544544
String.contains?(query, "issueLabels") ->
545545
Req.Test.json(conn, label_response(["urgent"]))
546546

547-
String.contains?(query, "projects(first: 100)") ->
547+
String.contains?(query, "projects(first: 100") ->
548548
Req.Test.json(conn, team_projects([project_map("p1", "Manhattan Rollout")]))
549549

550550
String.contains?(query, "issueCreate") ->
@@ -593,7 +593,7 @@ defmodule LinearCli.CLI.ProfileDefaultsTest do
593593
String.contains?(query, "issueLabels") ->
594594
Req.Test.json(conn, label_response(["urgent"]))
595595

596-
String.contains?(query, "projects(first: 100)") ->
596+
String.contains?(query, "projects(first: 100") ->
597597
Req.Test.json(conn, team_projects([]))
598598

599599
String.contains?(query, "issueCreate") ->

app/test/linear_cli/cli/projects_test.exs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ defmodule LinearCli.CLI.ProjectsTest do
7878
assert Projects.project_for([manhattan, brooklyn], "Manhattan") == manhattan
7979
end
8080

81+
test "an exact name wins over a weaker substring match" do
82+
exact = project(id: "1", name: "Wallet Service Extraction")
83+
partial = project(id: "2", name: "Wallet Service Extraction for Humans")
84+
85+
assert Projects.project_for([partial, exact], "Wallet Service Extraction") == exact
86+
end
87+
8188
test "an exact match by id wins outright even among several candidates" do
8289
exact = project(id: "abc-123", name: "Something Else")
8390
other = project(id: "other", name: "Something Else Entirely")

0 commit comments

Comments
 (0)