Skip to content

Commit 3608a53

Browse files
feat: support filter-only batch issue unassignment (#286)
* feat(issue): support filter-only batch unassignment * fix(issue): guard filter-only unassignment * fix(issue): cap filtered unassignment batches * fix(issue): skip unassigned filter matches * test(issue): cover assigned-only filter invocation --------- Co-authored-by: bougyman's bot <ruby-automation@users.noreply.github.com> Co-authored-by: bougyman <bougyman@users.noreply.github.com>
1 parent 9fedf65 commit 3608a53

18 files changed

Lines changed: 1288 additions & 83 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ LinearCli.CLI — Entry point; Optimus argument parsing a
9797
CLI.Commands.Teams — team list/favorite/unfavorite subcommands
9898
CLI.Commands.Projects — project list/favorite/unfavorite/update subcommands
9999
CLI.Commands.Issues.Read — issue read/list/show subcommands
100+
CLI.Commands.Issues.Filter — shared issue filter resolution
100101
CLI.Commands.Issues.Create — issue create subcommand
101102
CLI.Commands.Issues.Development — issue branch/PR subcommands
102103
CLI.Commands.Issues.Mutations — issue update/status/assign/comment subcommands

‎Readme.adoc‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,17 +289,38 @@ $ lc issue assign CRY-1234 <4>
289289
==== Unassign one or more issues
290290

291291
This command clears the assignee from each listed issue. It accepts explicit
292-
issue identifiers only.
292+
issue identifiers or a filter-only selection. Filter mode requires at least one
293+
narrowing selector: `--assignee`, `--team`, `--project`, `--state`, `--status`,
294+
or `--labels`. `--no-mine`, `--no-profile`, and `--all` modify that selection
295+
but are not selectors by themselves. Positional issue identifiers cannot be
296+
combined with filter options.
293297

294298
[source,sh]
295299
----
296300
$ lc issue unassign CRY-1234
297301
$ lc issue unassign CRY-1 CRY-2
298302
$ lc issue unassign --output json CRY-1 CRY-2
303+
$ lc issue unassign --no-profile --assignee "Alice Smith" --yes
304+
$ lc issue unassign --team CRY --project "Roadmap" --status "Human Review" --dry-run
305+
$ lc issue unassign --no-mine --all --labels Bug,Feature --yes
299306
----
300307

301308
Text output shows each updated issue and a confirmation. JSON output returns
302-
one object for one issue and an array for multiple issues.
309+
one object for one issue and an array for multiple issues. A filter with no
310+
matches prints `No issues matched.` in text mode and returns `[]` in JSON mode.
311+
Filter mode selects only your own issues by default; use `--no-mine` to include
312+
issues assigned to other users, or `--assignee` to select a named user. Already
313+
unassigned issues are skipped. Exact
314+
project and assignee matches are used without a prompt. Partial matches prompt
315+
you to choose a project or assignee. Before any filtered mutation, the command
316+
asks for confirmation; `--yes` skips that prompt and `--dry-run` prints the
317+
matching issues without changing them.
318+
The command processes at most 100 matches at a time. If more than 100 issues
319+
match, it prints a warning and processes only the first 100. Updates remain
320+
independent API calls, so a failed request can leave earlier matches already
321+
unassigned. In JSON mode, the warning goes to stderr so stdout stays valid
322+
JSON. The existing `--unassigned` issue-list filter is not a selector for this
323+
command because unassigning already-unassigned issues has no effect.
303324

304325
==== Create an issue
305326

‎app/lib/linear_cli/cli.ex‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -909,8 +909,62 @@ defmodule LinearCli.CLI do
909909
],
910910
unassign: [
911911
name: "unassign",
912-
about: "Clear the assignee from one or more issues (ISSUE_ID...)",
913-
allow_unknown_args: true
912+
about: "Clear assignees by issue ID or filter",
913+
allow_unknown_args: true,
914+
flags: [
915+
no_mine: [
916+
short: "-N",
917+
long: "--no-mine",
918+
help: "Include issues not assigned to the current user"
919+
],
920+
no_profile: [
921+
long: "--no-profile",
922+
help: "Ignore the active profile's team/project defaults"
923+
],
924+
all: [
925+
long: "--all",
926+
help: "Include completed and cancelled issues"
927+
],
928+
dry_run: [
929+
long: "--dry-run",
930+
help: "Preview matching issues without unassigning them"
931+
],
932+
yes: [
933+
short: "-y",
934+
long: "--yes",
935+
help: "Skip the confirmation prompt"
936+
]
937+
],
938+
options: [
939+
assignee: [
940+
short: "-a",
941+
long: "--assignee",
942+
help: "Filter by assignee name (exact or partial match)"
943+
],
944+
team: [short: "-t", long: "--team", help: "Filter by team key"],
945+
project: [
946+
short: "-p",
947+
long: "--project",
948+
help: "Filter by project name, URL, ID, or search term"
949+
],
950+
state: [
951+
long: "--state",
952+
help: "Filter by workflow state type(s) (comma-separated)",
953+
parser: &parse_states/1
954+
],
955+
status: [
956+
short: "-s",
957+
long: "--status",
958+
help: "Filter by workflow status name(s) (comma-separated)",
959+
parser: &parse_statuses/1
960+
],
961+
labels: [
962+
short: "-l",
963+
long: "--labels",
964+
help: "Filter by label name(s) (comma-separated, OR match)",
965+
parser: &parse_labels/1
966+
]
967+
]
914968
],
915969
take: [
916970
name: "take",
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
defmodule LinearCli.CLI.Commands.Issues.Filter do
2+
@moduledoc false
3+
4+
alias LinearCli.CLI.{Projects, Prompt}
5+
alias LinearCli.{Linear, Profiles}
6+
7+
@doc "Builds the shared issue-list input from CLI flags and options."
8+
def build_input(flags, options, ids \\ [], opts \\ []) do
9+
{team_key, project_source} = filter_sources(flags, options)
10+
project_resolution = Keyword.get(opts, :project_resolution, :permissive)
11+
12+
with {:ok, project_id} <- resolve_project_id(project_source, team_key, project_resolution),
13+
{:ok, assignee_id} <- resolve_assignee_id(Map.get(options, :assignee), team_key, opts) do
14+
{:ok, build_issue_input(flags, options, ids, opts, team_key, project_id, assignee_id)}
15+
end
16+
end
17+
18+
defp filter_sources(flags, options) do
19+
no_profile = Map.get(flags, :no_profile, false)
20+
team = Map.get(options, :team) || profile_default(no_profile, &Profiles.default_team/0)
21+
22+
project =
23+
Map.get(options, :project) || profile_default(no_profile, &Profiles.default_project/0)
24+
25+
{team, project}
26+
end
27+
28+
defp profile_default(true, _default), do: nil
29+
defp profile_default(false, default), do: default.()
30+
31+
defp build_issue_input(flags, options, ids, opts, team_key, project_id, assignee_id) do
32+
labels = Map.get(options, :labels) || []
33+
34+
%{
35+
ids: ids,
36+
mine: not Map.get(flags, :no_mine, false),
37+
unassigned: Keyword.get(opts, :unassigned, Map.get(flags, :unassigned, false)),
38+
assignee_id: assignee_id,
39+
assigned_only: Keyword.get(opts, :assigned_only, false),
40+
team_key: team_key,
41+
project_id: project_id,
42+
all: Map.get(flags, :all, false),
43+
state: Map.get(options, :state) || [],
44+
status: Map.get(options, :status) || [],
45+
labels: labels,
46+
include_labels:
47+
Keyword.get(opts, :include_labels, Map.get(flags, :include_labels, false) || labels != [])
48+
}
49+
end
50+
51+
defp resolve_project_id(nil, _team_key, _resolution), do: {:ok, nil}
52+
53+
defp resolve_project_id(search, team_key, resolution) when is_binary(team_key) do
54+
with {:ok, team} <- Linear.find_team(team_key),
55+
{:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}) do
56+
resolve_project_match(projects, search, resolution)
57+
end
58+
end
59+
60+
defp resolve_project_id(search, _team_key, resolution) do
61+
with {:ok, projects} <- Linear.projects() do
62+
resolve_project_match(projects, search, resolution)
63+
end
64+
end
65+
66+
defp resolve_project_match(projects, search, :permissive) do
67+
case Projects.project_for(projects, search) do
68+
nil -> {:ok, nil}
69+
project -> {:ok, project.id}
70+
end
71+
end
72+
73+
defp resolve_project_match(projects, search, :strict) do
74+
case Projects.project_scores(projects, search) do
75+
[] ->
76+
{:error, {:smells_bad, "No project found matching #{search}"}}
77+
78+
possibles ->
79+
case Enum.find(possibles, &(LinearCli.Linear.Project.match_score?(&1, search) == 100)) do
80+
nil ->
81+
case Projects.project_for(projects, search) do
82+
nil -> {:error, {:smells_bad, "No project found matching #{search}"}}
83+
project -> {:ok, project.id}
84+
end
85+
86+
project ->
87+
{:ok, project.id}
88+
end
89+
end
90+
end
91+
92+
defp resolve_assignee_id(nil, _team_key, _opts), do: {:ok, nil}
93+
94+
defp resolve_assignee_id(assignee, _team_key, _opts)
95+
when not is_binary(assignee) or assignee == "",
96+
do: {:ok, nil}
97+
98+
defp resolve_assignee_id(assignee, team_key, opts) do
99+
if Keyword.get(opts, :resolve_assignee, false) do
100+
with {:ok, members} <- assignee_members(team_key),
101+
{:ok, member} <- resolve_assignee_member(members, assignee) do
102+
{:ok, member.id}
103+
end
104+
else
105+
{:ok, nil}
106+
end
107+
end
108+
109+
defp assignee_members(team_key) when is_binary(team_key) do
110+
with {:ok, team} <- Linear.find_team(team_key), do: Linear.team_members(team.id)
111+
end
112+
113+
defp assignee_members(nil) do
114+
with {:ok, teams} <- Linear.teams() do
115+
teams
116+
|> Enum.reduce_while({:ok, %{}}, &collect_team_members/2)
117+
|> members_from_result()
118+
end
119+
end
120+
121+
defp collect_team_members(team, {:ok, members_by_id}) do
122+
case Linear.team_members(team.id) do
123+
{:ok, members} ->
124+
members_by_id =
125+
Enum.reduce(members, members_by_id, fn member, acc ->
126+
Map.put(acc, member.id, member)
127+
end)
128+
129+
{:cont, {:ok, members_by_id}}
130+
131+
{:error, reason} ->
132+
{:halt, {:error, reason}}
133+
end
134+
end
135+
136+
defp members_from_result({:ok, members_by_id}), do: {:ok, Map.values(members_by_id)}
137+
defp members_from_result(error), do: error
138+
139+
defp resolve_assignee_member(members, search) do
140+
normalized_search = String.downcase(search)
141+
exact = Enum.filter(members, &assignee_exact_match?(&1, normalized_search))
142+
143+
case exact do
144+
[member] ->
145+
{:ok, member}
146+
147+
[_ | _] ->
148+
{:error, ambiguous_assignee_error(exact, search)}
149+
150+
[] ->
151+
partial = Enum.filter(members, &assignee_partial_match?(&1, normalized_search))
152+
153+
case partial do
154+
[] -> {:error, unknown_assignee_error(members, search)}
155+
matches -> {:ok, Prompt.select("Assignee:", assignee_choices(matches))}
156+
end
157+
end
158+
end
159+
160+
defp assignee_exact_match?(member, search) do
161+
Enum.any?(assignee_names(member), &(String.downcase(&1) == search))
162+
end
163+
164+
defp assignee_partial_match?(member, search) do
165+
Enum.any?(assignee_names(member), &String.starts_with?(String.downcase(&1), search))
166+
end
167+
168+
defp assignee_names(member) do
169+
[Map.get(member, :name), Map.get(member, :display_name)]
170+
|> Enum.filter(&(is_binary(&1) and &1 != ""))
171+
|> Enum.uniq()
172+
end
173+
174+
defp assignee_choices(members) do
175+
members
176+
|> Enum.sort_by(&assignee_label/1)
177+
|> Enum.map(&{assignee_label(&1), &1})
178+
end
179+
180+
defp assignee_label(member) do
181+
case assignee_names(member) do
182+
[name, display_name] when name != display_name -> "#{name} (#{display_name})"
183+
[name | _] -> name
184+
[] -> member.id
185+
end
186+
end
187+
188+
defp ambiguous_assignee_error(members, search) do
189+
matches = Enum.map_join(assignee_choices(members), ", ", &elem(&1, 0))
190+
{:smells_bad, "Ambiguous assignee #{inspect(search)}: matches #{matches}"}
191+
end
192+
193+
defp unknown_assignee_error(members, search) do
194+
available = Enum.map_join(assignee_choices(members), ", ", &elem(&1, 0))
195+
{:smells_bad, "Unknown assignee #{inspect(search)}. Available: #{available}"}
196+
end
197+
end

0 commit comments

Comments
 (0)