Web labels: check who may change them, and that the label is this repo's

8975744 · Gabriel Morell · 2026-10-18 17:30

28 files +896 -117
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/labels.ex
+43 −3
@@ -59,9 +59,7 @@ defmodule GitGud.Labels do
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,6 +79,48 @@ defmodule GitGud.Labels do
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,15 +44,21 @@ defmodule GitGudWeb.IssueLive.Index do
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,6 +108,7 @@ defmodule GitGudWeb.IssueLive.Index do
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,7 +24,12 @@ defmodule GitGudWeb.IssueLive.New do
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,6 +37,7 @@ defmodule GitGudWeb.IssueLive.New do
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,16 +94,23 @@ defmodule GitGudWeb.IssueLive.New do
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,9 +118,15 @@ defmodule GitGudWeb.IssueLive.New 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,7 +154,8 @@ defmodule GitGudWeb.IssueLive.New do
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,11 +252,12 @@ defmodule GitGudWeb.IssueLive.New do
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,19 +122,41 @@ defmodule GitGudWeb.IssueLive.Show do
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,6 +348,9 @@ defmodule GitGudWeb.IssueLive.Show 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,22 +433,37 @@ defmodule GitGudWeb.IssueLive.Show do
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,6 +62,15 @@ defmodule GitGudWeb.LabelLive.Index do
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,21 +85,20 @@ defmodule GitGudWeb.LabelLive.Index do
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,26 +108,41 @@ defmodule GitGudWeb.LabelLive.Index do
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,6 +161,7 @@ defmodule GitGudWeb.LabelLive.Index do
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,6 +229,7 @@ defmodule GitGudWeb.LabelLive.Index do
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,8 +247,10 @@ defmodule GitGudWeb.LabelLive.Index do
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,6 +261,7 @@ defmodule GitGudWeb.LabelLive.Index do
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,7 +274,7 @@ defmodule GitGudWeb.LabelLive.Index do
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,6 +295,7 @@ defmodule GitGudWeb.LabelLive.Index do
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