neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Web labels: check who may change them, and that the label is this repo's
8975744 · Gabriel Morell · 2026-10-18 17:30
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/labels.ex
+43
−3
| 59 | 59 | end |
| 60 | 60 | |
| 61 | 61 | defp opted_out_ids(repo_id) do |
| 62 | − Repo.all( | |
| 63 | − from o in RepositoryOptOut, where: o.repository_id == ^repo_id, select: o.label_id | |
| 64 | − ) | |
| 62 | + Repo.all(from o in RepositoryOptOut, where: o.repository_id == ^repo_id, select: o.label_id) | |
| 65 | 63 | end |
| 66 | 64 | |
| 67 | 65 | @doc """ |
| 81 | 79 | |
| 82 | 80 | def get_label!(id), do: Repo.get!(Label, id) |
| 83 | 81 | |
| 82 | + @doc """ | |
| 83 | + The label among `labels` whose id is `id` — an integer, or the string | |
| 84 | + a client sent in an event — or `nil`. For checking a client-sent id | |
| 85 | + against the set the page offered (`list_labels/1`, what's attached) | |
| 86 | + rather than trusting it: a forged or foreign id finds nothing. | |
| 87 | + """ | |
| 88 | + def find_label(labels, id) do | |
| 89 | + case cast_id(id) do | |
| 90 | + {:ok, n} -> Enum.find(labels, &(&1.id == n)) | |
| 91 | + :error -> nil | |
| 92 | + end | |
| 93 | + end | |
| 94 | + | |
| 95 | + @doc "`repo`'s own label `id` (not an inherited one), or `nil`." | |
| 96 | + def get_repository_label(%Repository{id: rid}, id) do | |
| 97 | + case cast_id(id) do | |
| 98 | + {:ok, n} -> Repo.get_by(Label, id: n, repository_id: rid) | |
| 99 | + :error -> nil | |
| 100 | + end | |
| 101 | + end | |
| 102 | + | |
| 103 | + @doc "`org`'s label `id`, or `nil`." | |
| 104 | + def get_organization_label(%Organization{id: oid}, id) do | |
| 105 | + case cast_id(id) do | |
| 106 | + {:ok, n} -> Repo.get_by(Label, id: n, organization_id: oid) | |
| 107 | + :error -> nil | |
| 108 | + end | |
| 109 | + end | |
| 110 | + | |
| 111 | + def get_organization_label(nil, _id), do: nil | |
| 112 | + | |
| 113 | + defp cast_id(id) when is_integer(id), do: {:ok, id} | |
| 114 | + | |
| 115 | + defp cast_id(id) when is_binary(id) do | |
| 116 | + case Integer.parse(id) do | |
| 117 | + {n, ""} -> {:ok, n} | |
| 118 | + _ -> :error | |
| 119 | + end | |
| 120 | + end | |
| 121 | + | |
| 122 | + defp cast_id(_), do: :error | |
| 123 | + | |
| 84 | 124 | def create_label(%Repository{id: rid}, attrs) do |
| 85 | 125 | %Label{repository_id: rid} |
| 86 | 126 | |> Label.changeset(attrs) |
modified
lib/git_gud_web/live/issue_live/index.ex
+16
−9
| 44 | 44 | def handle_event("search", %{"q" => q}, socket), |
| 45 | 45 | do: {:noreply, socket |> assign(:search, q) |> load_issues()} |
| 46 | 46 | |
| 47 | − def handle_event("toggle_label", %{"id" => id}, socket) do | |
| 48 | − id = String.to_integer(id) | |
| 49 | − | |
| 50 | − selected = | |
| 51 | − if id in socket.assigns.selected_labels, | |
| 52 | − do: List.delete(socket.assigns.selected_labels, id), | |
| 53 | − else: [id | socket.assigns.selected_labels] | |
| 54 | − | |
| 55 | − {:noreply, socket |> assign(:selected_labels, selected) |> load_issues()} | |
| 47 | + # A filter, not a change — but only by the labels the page offers, so | |
| 48 | + # a forged id can't crash the view or filter by another repo's label. | |
| 49 | + def handle_event("toggle_label", params, socket) do | |
| 50 | + case Labels.find_label(socket.assigns.labels, params["id"]) do | |
| 51 | + nil -> | |
| 52 | + {:noreply, socket} | |
| 53 | + | |
| 54 | + %{id: id} -> | |
| 55 | + selected = | |
| 56 | + if id in socket.assigns.selected_labels, | |
| 57 | + do: List.delete(socket.assigns.selected_labels, id), | |
| 58 | + else: [id | socket.assigns.selected_labels] | |
| 59 | + | |
| 60 | + {:noreply, socket |> assign(:selected_labels, selected) |> load_issues()} | |
| 61 | + end | |
| 56 | 62 | end |
| 57 | 63 | |
| 58 | 64 | @impl true |
| 102 | 108 | <span class="ml-4">Labels:</span> |
| 103 | 109 | <button |
| 104 | 110 | :for={l <- @labels} |
| 111 | + id={"label-filter-#{l.id}"} | |
| 105 | 112 | type="button" |
| 106 | 113 | phx-click="toggle_label" |
| 107 | 114 | phx-value-id={l.id} |
modified
lib/git_gud_web/live/issue_live/new.ex
+36
−15
| 24 | 24 | Repositories.get_repository_by_path!(owner, name) |
| 25 | 25 | |> GitGud.Repo.preload([:owner, :organization]) |
| 26 | 26 | |
| 27 | − labels = Labels.list_labels(repo) | |
| 27 | + # As on GitHub, labels at creation are for those who triage the | |
| 28 | + # repository; anyone else's picks (and `?labels=`) are dropped. | |
| 29 | + can_label? = | |
| 30 | + GitGud.Organizations.can?(socket.assigns.current_scope.user, repo, :triage) == true | |
| 31 | + | |
| 32 | + labels = if can_label?, do: Labels.list_labels(repo), else: [] | |
| 28 | 33 | initial = autofill_from_params(params, labels) |
| 29 | 34 | |
| 30 | 35 | {:ok, |
| 32 | 37 | |> assign(:repo, repo) |
| 33 | 38 | |> assign(:handle, Storage.repo_handle(repo)) |
| 34 | 39 | |> GitGudWeb.RepoLive.Header.assign_chrome(repo) |
| 40 | + |> assign(:can_label?, can_label?) | |
| 35 | 41 | |> assign(:labels, labels) |
| 36 | 42 | |> assign(:selected_label_ids, initial.label_ids) |
| 37 | 43 | |> assign(:mode, :write) |
| 88 | 94 | {:noreply, socket} |
| 89 | 95 | end |
| 90 | 96 | |
| 91 | − def handle_event("toggle_label", %{"id" => id}, socket) do | |
| 92 | − id = String.to_integer(id) | |
| 93 | − current = socket.assigns.selected_label_ids | |
| 97 | + # `@labels` is empty for someone who can't label, so a forged or | |
| 98 | + # foreign id finds nothing there and changes nothing. | |
| 99 | + def handle_event("toggle_label", params, socket) do | |
| 100 | + case Labels.find_label(socket.assigns.labels, params["id"]) do | |
| 101 | + nil -> | |
| 102 | + {:noreply, socket} | |
| 103 | + | |
| 104 | + %{id: id} -> | |
| 105 | + current = socket.assigns.selected_label_ids | |
| 94 | 106 | |
| 95 | − next = | |
| 96 | − if id in current, | |
| 97 | − do: Enum.reject(current, &(&1 == id)), | |
| 98 | − else: [id | current] | |
| 107 | + next = | |
| 108 | + if id in current, | |
| 109 | + do: Enum.reject(current, &(&1 == id)), | |
| 110 | + else: [id | current] | |
| 99 | 111 | |
| 100 | − {:noreply, assign(socket, :selected_label_ids, next)} | |
| 112 | + {:noreply, assign(socket, :selected_label_ids, next)} | |
| 113 | + end | |
| 101 | 114 | end |
| 102 | 115 | |
| 103 | 116 | def handle_event("save", %{"issue" => attrs}, socket) do |
| 105 | 118 | |
| 106 | 119 | case Issues.create_issue(socket.assigns.repo, user, attrs) do |
| 107 | 120 | {:ok, issue} -> |
| 108 | − Enum.each(socket.assigns.selected_label_ids, fn lid -> | |
| 109 | − _ = Issues.attach_label(issue, Labels.get_label!(lid)) | |
| 110 | − end) | |
| 121 | + # Checked again against what the repository offers now: the | |
| 122 | + # selection came from the client, and a label may have gone. | |
| 123 | + pickable = | |
| 124 | + if socket.assigns.can_label?, do: Labels.list_labels(socket.assigns.repo), else: [] | |
| 125 | + | |
| 126 | + socket.assigns.selected_label_ids | |
| 127 | + |> Enum.map(&Labels.find_label(pickable, &1)) | |
| 128 | + |> Enum.reject(&is_nil/1) | |
| 129 | + |> Enum.each(&Issues.attach_label(issue, &1)) | |
| 111 | 130 | |
| 112 | 131 | # A reference written while opening the issue is as much a link |
| 113 | 132 | # as one added later. |
| 135 | 154 | # Keep the label selection sticky across `validate` events — the |
| 136 | 155 | # hidden inputs round-trip as `label_ids[]`. |
| 137 | 156 | defp update_selection(socket, %{"label_ids" => ids}) when is_list(ids) do |
| 138 | − parsed = ids |> Enum.flat_map(&parse_int/1) | |
| 157 | + offered = MapSet.new(socket.assigns.labels, & &1.id) | |
| 158 | + parsed = ids |> Enum.flat_map(&parse_int/1) |> Enum.filter(&(&1 in offered)) | |
| 139 | 159 | assign(socket, :selected_label_ids, parsed) |
| 140 | 160 | end |
| 141 | 161 |
| 232 | 252 | <% end %> |
| 233 | 253 | </div> |
| 234 | 254 | |
| 235 | − <section :if={@labels != []}> | |
| 255 | + <section :if={@can_label? and @labels != []}> | |
| 236 | 256 | <p class="text-sm font-semibold mb-1">Labels</p> |
| 237 | − <div class="flex flex-wrap gap-2"> | |
| 257 | + <div id="new-issue-labels" class="flex flex-wrap gap-2"> | |
| 238 | 258 | <button |
| 239 | 259 | :for={label <- @labels} |
| 260 | + id={"label-toggle-#{label.id}"} | |
| 240 | 261 | type="button" |
| 241 | 262 | phx-click="toggle_label" |
| 242 | 263 | phx-value-id={label.id} |
modified
lib/git_gud_web/live/issue_live/show.ex
+64
−24
| 122 | 122 | end |
| 123 | 123 | end |
| 124 | 124 | |
| 125 | − def handle_event("toggle_label", %{"id" => label_id}, socket) do | |
| 126 | − label = Labels.get_label!(label_id) | |
| 125 | + # The author or a repo admin, as for the title and body. Detaching | |
| 126 | + # matches what's attached — an org label this repo has since opted out | |
| 127 | + # of stays removable — and attaching only the repo's pickable set, so a | |
| 128 | + # forged id, or another repository's label, does nothing. | |
| 129 | + def handle_event("toggle_label", params, socket) do | |
| 127 | 130 | issue = socket.assigns.issue |
| 128 | − attached? = Enum.any?(issue.labels, &(&1.id == label.id)) | |
| 129 | 131 | |
| 130 | − if attached?, | |
| 131 | − do: Issues.detach_label(issue, label), | |
| 132 | − else: Issues.attach_label(issue, label) | |
| 132 | + attached = Labels.find_label(issue.labels, params["id"]) | |
| 133 | 133 | |
| 134 | − kind = if attached?, do: "unlabeled", else: "labeled" | |
| 135 | − :ok = Events.record(issue, kind, viewer(socket), %{name: label.name, color: label.color}) | |
| 134 | + label = | |
| 135 | + cond do | |
| 136 | + not can_edit?(socket.assigns) -> nil | |
| 137 | + attached -> attached | |
| 138 | + true -> Labels.find_label(Labels.list_labels(socket.assigns.repo), params["id"]) | |
| 139 | + end | |
| 136 | 140 | |
| 137 | − {:noreply, socket |> assign(:event_count, Events.count_for(issue)) |> reload()} | |
| 141 | + case label do | |
| 142 | + nil -> | |
| 143 | + message = | |
| 144 | + if can_edit?(socket.assigns), | |
| 145 | + do: "That label isn't one of this repository's.", | |
| 146 | + else: "You can't change this issue's labels." | |
| 147 | + | |
| 148 | + {:noreply, put_flash(socket, :error, message)} | |
| 149 | + | |
| 150 | + label -> | |
| 151 | + if attached, | |
| 152 | + do: Issues.detach_label(issue, label), | |
| 153 | + else: Issues.attach_label(issue, label) | |
| 154 | + | |
| 155 | + kind = if attached, do: "unlabeled", else: "labeled" | |
| 156 | + :ok = Events.record(issue, kind, viewer(socket), %{name: label.name, color: label.color}) | |
| 157 | + | |
| 158 | + {:noreply, socket |> assign(:event_count, Events.count_for(issue)) |> reload()} | |
| 159 | + end | |
| 138 | 160 | end |
| 139 | 161 | |
| 140 | 162 | def handle_event("edit_comment", %{"id" => id}, socket) do |
| 326 | 348 | end |
| 327 | 349 | end |
| 328 | 350 | |
| 351 | + defp label_choices(offered, attached), | |
| 352 | + do: offered ++ Enum.reject(attached, fn l -> Enum.any?(offered, &(&1.id == l.id)) end) | |
| 353 | + | |
| 329 | 354 | defp reload(socket) do |
| 330 | 355 | assign(socket, :issue, Issues.get_issue!(socket.assigns.repo, socket.assigns.issue.number)) |
| 331 | 356 | end |
| 408 | 433 | <% end %> |
| 409 | 434 | </section> |
| 410 | 435 | |
| 411 | − <section :if={@all_labels != []}> | |
| 436 | + <section :if={@all_labels != [] or @issue.labels != []}> | |
| 412 | 437 | <h2 class="font-semibold mb-2">Labels</h2> |
| 413 | − <div class="flex flex-wrap gap-1"> | |
| 414 | − <button | |
| 415 | − :for={l <- @all_labels} | |
| 416 | − type="button" | |
| 417 | − phx-click="toggle_label" | |
| 418 | − phx-value-id={l.id} | |
| 419 | − class={[ | |
| 420 | − "badge", | |
| 421 | − Enum.any?(@issue.labels, &(&1.id == l.id)) || "opacity-40" | |
| 422 | − ]} | |
| 423 | − style={"background-color: " <> l.color <> "; color: white"} | |
| 424 | − > | |
| 425 | − {l.name} | |
| 426 | − </button> | |
| 438 | + <div id="issue-labels" class="flex flex-wrap gap-1"> | |
| 439 | + <%= if can_edit?(assigns) do %> | |
| 440 | + <%!-- Attached labels the repo no longer offers (an org label | |
| 441 | + it opted out of) still show, so they can be removed. --%> | |
| 442 | + <button | |
| 443 | + :for={l <- label_choices(@all_labels, @issue.labels)} | |
| 444 | + id={"label-toggle-#{l.id}"} | |
| 445 | + type="button" | |
| 446 | + phx-click="toggle_label" | |
| 447 | + phx-value-id={l.id} | |
| 448 | + class={[ | |
| 449 | + "badge", | |
| 450 | + Enum.any?(@issue.labels, &(&1.id == l.id)) || "opacity-40" | |
| 451 | + ]} | |
| 452 | + style={"background-color: " <> l.color <> "; color: white"} | |
| 453 | + > | |
| 454 | + {l.name} | |
| 455 | + </button> | |
| 456 | + <% else %> | |
| 457 | + <span :if={@issue.labels == []} class="text-sm opacity-60">None yet</span> | |
| 458 | + <span | |
| 459 | + :for={l <- @issue.labels} | |
| 460 | + id={"label-#{l.id}"} | |
| 461 | + class="badge" | |
| 462 | + style={"background-color: " <> l.color <> "; color: white"} | |
| 463 | + > | |
| 464 | + {l.name} | |
| 465 | + </span> | |
| 466 | + <% end %> | |
| 427 | 467 | </div> |
| 428 | 468 | </section> |
| 429 | 469 |
modified
lib/git_gud_web/live/label_live/index.ex
+53
−24
| 62 | 62 | {:noreply, assign_form(socket, cs)} |
| 63 | 63 | end |
| 64 | 64 | |
| 65 | + # Everything below changes the repository's labels, so it is a | |
| 66 | + # repository admin's to do (`can_admin?`, from `assign_chrome/2`) — | |
| 67 | + # checked here, not just by hiding the buttons, since any signed-in | |
| 68 | + # reader can push these events. | |
| 69 | + def handle_event(event, _params, %{assigns: %{can_admin?: false}} = socket) | |
| 70 | + when event in ~w(save delete opt_out opt_in) do | |
| 71 | + {:noreply, put_flash(socket, :error, "Only a repository admin can change its labels.")} | |
| 72 | + end | |
| 73 | + | |
| 65 | 74 | def handle_event("save", %{"label" => attrs}, socket) do |
| 66 | 75 | case Labels.create_label(socket.assigns.repo, attrs) do |
| 67 | 76 | {:ok, _label} -> |
| 76 | 85 | end |
| 77 | 86 | end |
| 78 | 87 | |
| 79 | − def handle_event("delete", %{"id" => id}, socket) do | |
| 80 | − label = Labels.get_label!(id) | |
| 81 | − | |
| 82 | − # Inherited org labels can't be deleted from the repo view — use | |
| 83 | − # opt-out instead. Belt-and-braces gate against a forged event. | |
| 84 | − cond do | |
| 85 | − Label.org_label?(label) -> | |
| 88 | + # Only this repository's own labels: an inherited org label is edited | |
| 89 | + # at the org level (opt out to hide it here), and an id from anywhere | |
| 90 | + # else is somebody else's label. | |
| 91 | + def handle_event("delete", params, socket) do | |
| 92 | + case Labels.get_repository_label(socket.assigns.repo, params["id"]) do | |
| 93 | + nil -> | |
| 86 | 94 | {:noreply, |
| 87 | 95 | put_flash( |
| 88 | 96 | socket, |
| 89 | 97 | :error, |
| 90 | − "Org labels can only be edited at the org level. Opt out to hide." | |
| 98 | + "Only this repository's own labels can be deleted here. Opt out to hide an org label." | |
| 91 | 99 | )} |
| 92 | 100 | |
| 93 | − true -> | |
| 101 | + label -> | |
| 94 | 102 | {:ok, _} = Labels.delete_label(label) |
| 95 | 103 | |
| 96 | 104 | {:noreply, |
| 100 | 108 | end |
| 101 | 109 | end |
| 102 | 110 | |
| 103 | − def handle_event("opt_out", %{"id" => id}, socket) do | |
| 104 | − label = Labels.get_label!(id) | |
| 105 | − _ = Labels.opt_out(socket.assigns.repo, label) | |
| 111 | + def handle_event("opt_out", params, socket) do | |
| 112 | + case org_label(socket, params["id"]) do | |
| 113 | + nil -> | |
| 114 | + {:noreply, put_flash(socket, :error, "That isn't one of this organization's labels.")} | |
| 106 | 115 | |
| 107 | − {:noreply, | |
| 108 | − socket | |
| 109 | − |> assign_labels() | |
| 110 | − |> put_flash(:info, "Hidden from this repo.")} | |
| 116 | + label -> | |
| 117 | + _ = Labels.opt_out(socket.assigns.repo, label) | |
| 118 | + | |
| 119 | + {:noreply, | |
| 120 | + socket | |
| 121 | + |> assign_labels() | |
| 122 | + |> put_flash(:info, "Hidden from this repo.")} | |
| 123 | + end | |
| 111 | 124 | end |
| 112 | 125 | |
| 113 | − def handle_event("opt_in", %{"id" => id}, socket) do | |
| 114 | − label = Labels.get_label!(id) | |
| 115 | − {:ok, _} = Labels.opt_in(socket.assigns.repo, label) | |
| 126 | + def handle_event("opt_in", params, socket) do | |
| 127 | + case org_label(socket, params["id"]) do | |
| 128 | + nil -> | |
| 129 | + {:noreply, put_flash(socket, :error, "That isn't one of this organization's labels.")} | |
| 116 | 130 | |
| 117 | − {:noreply, | |
| 118 | − socket | |
| 119 | − |> assign_labels() | |
| 120 | − |> put_flash(:info, "Re-enabled for this repo.")} | |
| 131 | + label -> | |
| 132 | + {:ok, _} = Labels.opt_in(socket.assigns.repo, label) | |
| 133 | + | |
| 134 | + {:noreply, | |
| 135 | + socket | |
| 136 | + |> assign_labels() | |
| 137 | + |> put_flash(:info, "Re-enabled for this repo.")} | |
| 138 | + end | |
| 121 | 139 | end |
| 122 | 140 | |
| 141 | + # A label of the organization that owns this repository — the only | |
| 142 | + # ones it inherits, so the only ones it can opt out of or back into. | |
| 143 | + defp org_label(socket, id), | |
| 144 | + do: Labels.get_organization_label(socket.assigns.repo.organization, id) | |
| 145 | + | |
| 123 | 146 | @impl true |
| 124 | 147 | def render(assigns) do |
| 125 | 148 | ~H""" |
| 138 | 161 | <h1 class="text-xl font-semibold">Labels</h1> |
| 139 | 162 | |
| 140 | 163 | <.form |
| 164 | + :if={@can_admin?} | |
| 141 | 165 | for={@form} |
| 142 | 166 | id="label-form" |
| 143 | 167 | phx-change="validate" |
| 205 | 229 | <li :if={@labels == []} class="opacity-60 text-sm">No labels yet.</li> |
| 206 | 230 | <li |
| 207 | 231 | :for={label <- @labels} |
| 232 | + id={"label-#{label.id}"} | |
| 208 | 233 | class="flex items-center gap-2 border border-base-200 rounded px-2 py-1" |
| 209 | 234 | > |
| 210 | 235 | <span |
| 222 | 247 | inherited |
| 223 | 248 | </span> |
| 224 | 249 | <%= cond do %> |
| 250 | + <% not @can_admin? -> %> | |
| 225 | 251 | <% Map.get(label, :inherited?, false) -> %> |
| 226 | 252 | <button |
| 253 | + id={"label-hide-#{label.id}"} | |
| 227 | 254 | type="button" |
| 228 | 255 | class="btn btn-xs" |
| 229 | 256 | phx-click="opt_out" |
| 234 | 261 | </button> |
| 235 | 262 | <% true -> %> |
| 236 | 263 | <button |
| 264 | + id={"label-delete-#{label.id}"} | |
| 237 | 265 | type="button" |
| 238 | 266 | class="btn btn-xs btn-ghost" |
| 239 | 267 | phx-click="delete" |
| 246 | 274 | </li> |
| 247 | 275 | </ul> |
| 248 | 276 | |
| 249 | − <details :if={@hidden_labels != []} class="border border-base-300 rounded"> | |
| 277 | + <details :if={@can_admin? and @hidden_labels != []} class="border border-base-300 rounded"> | |
| 250 | 278 | <summary class="cursor-pointer px-3 py-2 text-sm flex items-center gap-2 hover:bg-base-200"> |
| 251 | 279 | <.icon name="hero-eye-slash" class="size-4 opacity-60" /> Hidden org labels |
| 252 | 280 | <span class="badge badge-xs opacity-70">{length(@hidden_labels)}</span> |
| 267 | 295 | org |
| 268 | 296 | </span> |
| 269 | 297 | <button |
| 298 | + id={"label-unhide-#{label.id}"} | |
| 270 | 299 | type="button" |
| 271 | 300 | class="btn btn-xs" |
| 272 | 301 | phx-click="opt_in" |
modified
lib/git_gud_web/live/org_live/labels.ex
+27
−9
Click to load diff…
modified
lib/git_gud_web/live/pr_live/index.ex
+16
−9
Click to load diff…
modified
lib/git_gud_web/live/pr_live/show.ex
+64
−24
Click to load diff…
added
test/git_gud_web/live/issue_live/label_toggle_test.exs
+123
−0
Click to load diff…
added
test/git_gud_web/live/issue_live/new_labels_test.exs
+111
−0
Click to load diff…
added
test/git_gud_web/live/label_live/index_test.exs
+160
−0
Click to load diff…
added
test/git_gud_web/live/org_live/labels_test.exs
+89
−0
Click to load diff…
added
test/git_gud_web/live/pr_live/label_toggle_test.exs
+94
−0
Click to load diff…
Parents: df92b0a