|
| 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