Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ LinearCli.CLI — Entry point; Optimus argument parsing a
CLI.Commands.Teams — team list/favorite/unfavorite subcommands
CLI.Commands.Projects — project list/favorite/unfavorite/update subcommands
CLI.Commands.Issues.Read — issue read/list/show subcommands
CLI.Commands.Issues.Filter — shared issue filter resolution
CLI.Commands.Issues.Create — issue create subcommand
CLI.Commands.Issues.Development — issue branch/PR subcommands
CLI.Commands.Issues.Mutations — issue update/status/assign/comment subcommands
Expand Down
25 changes: 23 additions & 2 deletions Readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -289,17 +289,38 @@ $ lc issue assign CRY-1234 <4>
==== Unassign one or more issues

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

[source,sh]
----
$ lc issue unassign CRY-1234
$ lc issue unassign CRY-1 CRY-2
$ lc issue unassign --output json CRY-1 CRY-2
$ lc issue unassign --no-profile --assignee "Alice Smith" --yes
$ lc issue unassign --team CRY --project "Roadmap" --status "Human Review" --dry-run
$ lc issue unassign --no-mine --all --labels Bug,Feature --yes
----

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

==== Create an issue

Expand Down
58 changes: 56 additions & 2 deletions app/lib/linear_cli/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -909,8 +909,62 @@ defmodule LinearCli.CLI do
],
unassign: [
name: "unassign",
about: "Clear the assignee from one or more issues (ISSUE_ID...)",
allow_unknown_args: true
about: "Clear assignees by issue ID or filter",
allow_unknown_args: true,
flags: [
no_mine: [
short: "-N",
long: "--no-mine",
help: "Include issues not assigned to the current user"
],
no_profile: [
long: "--no-profile",
help: "Ignore the active profile's team/project defaults"
],
all: [
long: "--all",
help: "Include completed and cancelled issues"
],
dry_run: [
long: "--dry-run",
help: "Preview matching issues without unassigning them"
],
yes: [
short: "-y",
long: "--yes",
help: "Skip the confirmation prompt"
]
],
options: [
assignee: [
short: "-a",
long: "--assignee",
help: "Filter by assignee name (exact or partial match)"
],
team: [short: "-t", long: "--team", help: "Filter by team key"],
project: [
short: "-p",
long: "--project",
help: "Filter by project name, URL, ID, or search term"
],
state: [
long: "--state",
help: "Filter by workflow state type(s) (comma-separated)",
parser: &parse_states/1
],
status: [
short: "-s",
long: "--status",
help: "Filter by workflow status name(s) (comma-separated)",
parser: &parse_statuses/1
],
labels: [
short: "-l",
long: "--labels",
help: "Filter by label name(s) (comma-separated, OR match)",
parser: &parse_labels/1
]
]
],
take: [
name: "take",
Expand Down
197 changes: 197 additions & 0 deletions app/lib/linear_cli/cli/commands/issues/filter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
defmodule LinearCli.CLI.Commands.Issues.Filter do
@moduledoc false

alias LinearCli.CLI.{Projects, Prompt}
alias LinearCli.{Linear, Profiles}

@doc "Builds the shared issue-list input from CLI flags and options."
def build_input(flags, options, ids \\ [], opts \\ []) do
{team_key, project_source} = filter_sources(flags, options)
project_resolution = Keyword.get(opts, :project_resolution, :permissive)

with {:ok, project_id} <- resolve_project_id(project_source, team_key, project_resolution),
{:ok, assignee_id} <- resolve_assignee_id(Map.get(options, :assignee), team_key, opts) do
{:ok, build_issue_input(flags, options, ids, opts, team_key, project_id, assignee_id)}
end
end

defp filter_sources(flags, options) do
no_profile = Map.get(flags, :no_profile, false)
team = Map.get(options, :team) || profile_default(no_profile, &Profiles.default_team/0)

project =
Map.get(options, :project) || profile_default(no_profile, &Profiles.default_project/0)

{team, project}
end

defp profile_default(true, _default), do: nil
defp profile_default(false, default), do: default.()

defp build_issue_input(flags, options, ids, opts, team_key, project_id, assignee_id) do
labels = Map.get(options, :labels) || []

%{
ids: ids,
mine: not Map.get(flags, :no_mine, false),
unassigned: Keyword.get(opts, :unassigned, Map.get(flags, :unassigned, false)),
assignee_id: assignee_id,
assigned_only: Keyword.get(opts, :assigned_only, false),
team_key: team_key,
project_id: project_id,
all: Map.get(flags, :all, false),
state: Map.get(options, :state) || [],
status: Map.get(options, :status) || [],
labels: labels,
include_labels:
Keyword.get(opts, :include_labels, Map.get(flags, :include_labels, false) || labels != [])
}
end

defp resolve_project_id(nil, _team_key, _resolution), do: {:ok, nil}

defp resolve_project_id(search, team_key, resolution) when is_binary(team_key) do
with {:ok, team} <- Linear.find_team(team_key),
{:ok, projects} <- Linear.projects_by_team(team.id, %{search: search}) do
resolve_project_match(projects, search, resolution)
end
end

defp resolve_project_id(search, _team_key, resolution) do
with {:ok, projects} <- Linear.projects() do
resolve_project_match(projects, search, resolution)
end
end

defp resolve_project_match(projects, search, :permissive) do
case Projects.project_for(projects, search) do
nil -> {:ok, nil}
project -> {:ok, project.id}
end
end

defp resolve_project_match(projects, search, :strict) do
case Projects.project_scores(projects, search) do
[] ->
{:error, {:smells_bad, "No project found matching #{search}"}}

possibles ->
case Enum.find(possibles, &(LinearCli.Linear.Project.match_score?(&1, search) == 100)) do
nil ->
case Projects.project_for(projects, search) do
nil -> {:error, {:smells_bad, "No project found matching #{search}"}}
project -> {:ok, project.id}
end

project ->
{:ok, project.id}
end
end
end

defp resolve_assignee_id(nil, _team_key, _opts), do: {:ok, nil}

defp resolve_assignee_id(assignee, _team_key, _opts)
when not is_binary(assignee) or assignee == "",
do: {:ok, nil}

defp resolve_assignee_id(assignee, team_key, opts) do
if Keyword.get(opts, :resolve_assignee, false) do
with {:ok, members} <- assignee_members(team_key),
{:ok, member} <- resolve_assignee_member(members, assignee) do
{:ok, member.id}
end
else
{:ok, nil}
end
end

defp assignee_members(team_key) when is_binary(team_key) do
with {:ok, team} <- Linear.find_team(team_key), do: Linear.team_members(team.id)
end

defp assignee_members(nil) do
with {:ok, teams} <- Linear.teams() do
teams
|> Enum.reduce_while({:ok, %{}}, &collect_team_members/2)
|> members_from_result()
end
end

defp collect_team_members(team, {:ok, members_by_id}) do
case Linear.team_members(team.id) do
{:ok, members} ->
members_by_id =
Enum.reduce(members, members_by_id, fn member, acc ->
Map.put(acc, member.id, member)
end)

{:cont, {:ok, members_by_id}}

{:error, reason} ->
{:halt, {:error, reason}}
end
end

defp members_from_result({:ok, members_by_id}), do: {:ok, Map.values(members_by_id)}
defp members_from_result(error), do: error

defp resolve_assignee_member(members, search) do
normalized_search = String.downcase(search)
exact = Enum.filter(members, &assignee_exact_match?(&1, normalized_search))

case exact do
[member] ->
{:ok, member}

[_ | _] ->
{:error, ambiguous_assignee_error(exact, search)}

[] ->
partial = Enum.filter(members, &assignee_partial_match?(&1, normalized_search))

case partial do
[] -> {:error, unknown_assignee_error(members, search)}
matches -> {:ok, Prompt.select("Assignee:", assignee_choices(matches))}
end
end
end

defp assignee_exact_match?(member, search) do
Enum.any?(assignee_names(member), &(String.downcase(&1) == search))
end

defp assignee_partial_match?(member, search) do
Enum.any?(assignee_names(member), &String.starts_with?(String.downcase(&1), search))
end

defp assignee_names(member) do
[Map.get(member, :name), Map.get(member, :display_name)]
|> Enum.filter(&(is_binary(&1) and &1 != ""))
|> Enum.uniq()
end

defp assignee_choices(members) do
members
|> Enum.sort_by(&assignee_label/1)
|> Enum.map(&{assignee_label(&1), &1})
end

defp assignee_label(member) do
case assignee_names(member) do
[name, display_name] when name != display_name -> "#{name} (#{display_name})"
[name | _] -> name
[] -> member.id
end
end

defp ambiguous_assignee_error(members, search) do
matches = Enum.map_join(assignee_choices(members), ", ", &elem(&1, 0))
{:smells_bad, "Ambiguous assignee #{inspect(search)}: matches #{matches}"}
end

defp unknown_assignee_error(members, search) do
available = Enum.map_join(assignee_choices(members), ", ", &elem(&1, 0))
{:smells_bad, "Unknown assignee #{inspect(search)}. Available: #{available}"}
end
end
Loading
Loading