Issues: only the author or a triager closes and reopens from the web

9af3f43 · Gabriel Morell · 2026-10-18 18:08

10 files +94 -5
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud_web/live/issue_live/show.ex
+22 −5
@@ -51,6 +51,16 @@ defmodule GitGudWeb.IssueLive.Show do
51 51
52 52 defp can_edit?(_assigns), do: false
53 53
54 + # Closing and reopening: the author, or anyone with triage access on
55 + # the repository (which an admin has) — the rule the mobile API's
56 + # `set_state` applies too.
57 + defp can_change_state?(%{current_scope: %{user: %{id: uid} = user}} = assigns),
58 + do:
59 + assigns.issue.author_id == uid or
60 + GitGud.Organizations.can?(user, assigns.repo, :triage) == true
61 +
62 + defp can_change_state?(_assigns), do: false
63 +
54 64 # Same rule as the title: the comment's author, or a repo admin. A
55 65 # deleted comment is nobody's to edit.
56 66 defp can_edit_comment?(%{current_scope: %{user: %{id: uid}}} = assigns, comment) do
@@ -67,13 +77,18 @@ defmodule GitGudWeb.IssueLive.Show do
67 77 @impl true
68 78 def handle_event("toggle_state", _params, socket) do
69 79 issue = socket.assigns.issue
70 − new_state = if issue.state == "open", do: "closed", else: "open"
71 − {:ok, _} = Issues.set_state(issue, new_state)
72 80
73 − kind = if new_state == "closed", do: "closed", else: "reopened"
74 − :ok = Events.record(issue, kind, viewer(socket))
81 + if can_change_state?(socket.assigns) do
82 + new_state = if issue.state == "open", do: "closed", else: "open"
83 + {:ok, _} = Issues.set_state(issue, new_state)
84 +
85 + kind = if new_state == "closed", do: "closed", else: "reopened"
86 + :ok = Events.record(issue, kind, viewer(socket))
75 87
76 − {:noreply, reload(socket)}
88 + {:noreply, reload(socket)}
89 + else
90 + {:noreply, socket}
91 + end
77 92 end
78 93
79 94 def handle_event("edit_title", _params, socket) do
@@ -577,6 +592,8 @@ defmodule GitGudWeb.IssueLive.Show do
577 592 >{@comment_body}</textarea>
578 593 <div class="flex justify-end gap-2">
579 594 <button
595 + :if={can_change_state?(assigns)}
596 + id="issue-toggle-state"
580 597 type="button"
581 598 phx-click="toggle_state"
582 599 class="btn btn-sm btn-ghost"
added test/git_gud_web/live/issue_live/state_toggle_test.exs
+72 −0
@@ -0,0 +1,72 @@
1 +defmodule GitGudWeb.IssueLive.StateToggleTest do
2 + @moduledoc """
3 + Closing and reopening an issue from its page is for its author or
4 + someone with triage access — the mobile API's rule. Anyone else, signed
5 + in or not, sees no button, and pushing the event by hand changes
6 + nothing.
7 + """
8 +
9 + use GitGudWeb.ConnCase, async: false
10 +
11 + import Phoenix.LiveViewTest
12 + import GitGud.AccountsFixtures
13 + import GitGud.ForgeFixtures
14 +
15 + alias GitGud.Issues
16 + alias GitGud.Repo
17 + alias GitGud.Repositories
18 +
19 + defp handle(repo),
20 + do: Repositories.Storage.repo_handle(Repo.preload(repo, [:owner, :organization]))
21 +
22 + defp issue_path(repo, issue), do: ~p"/r/#{handle(repo)}/#{repo.name}/issues/#{issue.number}"
23 + defp state(repo, issue), do: Issues.get_issue!(repo, issue.number).state
24 +
25 + setup do
26 + {owner, repo} = repository_fixture(%{visibility: "public"})
27 + author = user_fixture()
28 + issue = issue_fixture(repo, author)
29 + %{owner: owner, repo: repo, author: author, issue: issue}
30 + end
31 +
32 + test "the author closes and reopens their issue", %{conn: conn} = ctx do
33 + {:ok, lv, _} = live(log_in_user(conn, ctx.author), issue_path(ctx.repo, ctx.issue))
34 +
35 + lv |> element("#issue-toggle-state") |> render_click()
36 + assert state(ctx.repo, ctx.issue) == "closed"
37 +
38 + lv |> element("#issue-toggle-state") |> render_click()
39 + assert state(ctx.repo, ctx.issue) == "open"
40 + end
41 +
42 + test "a repository admin closes someone else's issue", %{conn: conn} = ctx do
43 + {:ok, lv, _} = live(log_in_user(conn, ctx.owner), issue_path(ctx.repo, ctx.issue))
44 +
45 + lv |> element("#issue-toggle-state") |> render_click()
46 + assert state(ctx.repo, ctx.issue) == "closed"
47 + end
48 +
49 + test "another signed-in user gets no button, and the event does nothing", %{conn: conn} = ctx do
50 + {:ok, lv, _} = live(log_in_user(conn, user_fixture()), issue_path(ctx.repo, ctx.issue))
51 +
52 + refute has_element?(lv, "#issue-toggle-state")
53 + _ = render_hook(lv, "toggle_state", %{})
54 + assert state(ctx.repo, ctx.issue) == "open"
55 + end
56 +
57 + test "a reopen by hand is refused too", %{conn: conn} = ctx do
58 + {:ok, _} = Issues.set_state(ctx.issue, "closed")
59 + {:ok, lv, _} = live(log_in_user(conn, user_fixture()), issue_path(ctx.repo, ctx.issue))
60 +
61 + _ = render_hook(lv, "toggle_state", %{})
62 + assert state(ctx.repo, ctx.issue) == "closed"
63 + end
64 +
65 + test "a visitor who isn't signed in can't close it", %{conn: conn} = ctx do
66 + {:ok, lv, _} = live(conn, issue_path(ctx.repo, ctx.issue))
67 +
68 + refute has_element?(lv, "#issue-toggle-state")
69 + _ = render_hook(lv, "toggle_state", %{})
70 + assert state(ctx.repo, ctx.issue) == "open"
71 + end
72 +end

Parents: 8975744