Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions app/lib/linear_cli/cli/commands.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand All @@ -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}")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
8 changes: 5 additions & 3 deletions app/lib/linear_cli/cli/issue_helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
17 changes: 9 additions & 8 deletions app/lib/linear_cli/cli/projects.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
110 changes: 104 additions & 6 deletions app/lib/linear_cli/linear/project.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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("-")
Expand Down Expand Up @@ -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()} }
}
}
Expand All @@ -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
Expand Down
20 changes: 15 additions & 5 deletions app/test/linear_cli/cli/issue_commands_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]})
Expand All @@ -222,7 +232,7 @@ defmodule LinearCli.CLI.IssueCommandsTest do
"--team",
"ENG",
"--project",
"Manhattan Rollout"
"Wallet Service Extraction"
])
end)

Expand Down Expand Up @@ -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" => %{
Expand Down Expand Up @@ -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}}}
])
Expand Down
8 changes: 4 additions & 4 deletions app/test/linear_cli/cli/issue_helpers_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -460,7 +460,7 @@ defmodule LinearCli.CLI.IssueHelpersTest do
}
}
}},
{"projects(first: 100)",
{"projects(first: 100",
team_projects([
%{
"id" => "p1",
Expand Down
8 changes: 4 additions & 4 deletions app/test/linear_cli/cli/profile_defaults_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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") ->
Expand Down Expand Up @@ -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") ->
Expand Down Expand Up @@ -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") ->
Expand Down Expand Up @@ -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") ->
Expand Down
7 changes: 7 additions & 0 deletions app/test/linear_cli/cli/projects_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion app/test/linear_cli/cli_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
}
}
Expand Down
Loading