Let the author or a maintainer reopen a closed pull request

51e1414 · Gabriel Morell · 2026-09-09 22:38

3 files +219 -6
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/pull_requests.ex
+9 −0
@@ -534,6 +534,15 @@ defmodule GitGud.PullRequests do
534 534 |> Repo.update()
535 535 end
536 536
537 + @doc """
538 + Open or close a pull request.
539 +
540 + A merged PR is terminal — reopening one would claim the merge commit
541 + never happened, so it's refused here rather than left to each caller
542 + to remember.
543 + """
544 + def set_state(%PullRequest{state: "merged"}, "open"), do: {:error, :merged}
545 +
537 546 def set_state(%PullRequest{} = pr, state) when state in ["open", "closed"] do
538 547 pr
539 548 |> PullRequest.state_changeset(state)
modified lib/git_gud_web/live/pr_live/show.ex
+46 −6
@@ -75,6 +75,17 @@ defmodule GitGudWeb.PrLive.Show do
75 75
76 76 defp can_edit_title?(_assigns), do: false
77 77
78 + # Closing and reopening are the author's or a maintainer's to do.
79 + # `can_admin?` already covers repo owner and org admin.
80 + defp can_change_state?(%{current_scope: %{user: %{id: uid}}} = assigns),
81 + do: assigns.can_admin? or assigns.pr.author_id == uid
82 +
83 + defp can_change_state?(_assigns), do: false
84 +
85 + # A merged PR is terminal — there is nothing to reopen.
86 + defp can_reopen?(assigns),
87 + do: assigns.pr.state == "closed" and can_change_state?(assigns)
88 +
78 89 # Same rule as the title: the comment's author, or a repo admin. A
79 90 # deleted comment is nobody's to edit.
80 91 defp can_edit_comment?(%{current_scope: %{user: %{id: uid}}} = assigns, comment) do
@@ -159,13 +170,26 @@ defmodule GitGudWeb.PrLive.Show do
159 170
160 171 def handle_event("toggle_state", _params, socket) do
161 172 pr = socket.assigns.pr
162 new_state = if pr.state == "open", do: "closed", else: "open"
163 {:ok, _} = PullRequests.set_state(pr, new_state)
164 173
165 kind = if new_state == "closed", do: "closed", else: "reopened"
166 :ok = Events.record(pr, kind, viewer(socket))
174 + if can_change_state?(socket.assigns) do
175 + new_state = if pr.state == "open", do: "closed", else: "open"
167 176
168 {:noreply, socket |> assign(:event_count, Events.count_for(pr)) |> reload()}
177 + case PullRequests.set_state(pr, new_state) do
178 + {:ok, _} ->
179 + kind = if new_state == "closed", do: "closed", else: "reopened"
180 + :ok = Events.record(pr, kind, viewer(socket))
181 +
182 + {:noreply, socket |> assign(:event_count, Events.count_for(pr)) |> reload()}
183 +
184 + {:error, :merged} ->
185 + {:noreply, put_flash(socket, :error, "A merged pull request can't be reopened.")}
186 +
187 + {:error, _cs} ->
188 + {:noreply, put_flash(socket, :error, "Could not change the state.")}
189 + end
190 + else
191 + {:noreply, put_flash(socket, :error, "Only the author or a repo admin can do that.")}
192 + end
169 193 end
170 194
171 195 def handle_event("edit_title", _params, socket) do
@@ -530,6 +554,17 @@ defmodule GitGudWeb.PrLive.Show do
530 554 />
531 555 </section>
532 556
557 + <section :if={@pr.state == "closed"} class="card bg-base-200 p-4 space-y-2">
558 + <p class="text-sm flex items-center gap-1.5">
559 + <.icon name="hero-x-circle" class="size-4 text-error" /> This pull request is closed.
560 + </p>
561 + <div :if={can_reopen?(assigns)}>
562 + <button type="button" phx-click="toggle_state" class="btn btn-sm btn-primary">
563 + <.icon name="hero-arrow-path" class="size-4" /> Reopen
564 + </button>
565 + </div>
566 + </section>
567 +
533 568 <section :if={@pr.state == "open"} class="card bg-base-200 p-4 space-y-2">
534 569 <p class="text-sm">
535 570 <%= cond do %>
@@ -552,7 +587,12 @@ defmodule GitGudWeb.PrLive.Show do
552 587 >
553 588 Merge
554 589 </button>
555 <button type="button" phx-click="toggle_state" class="btn btn-sm btn-ghost">
590 + <button
591 + :if={can_change_state?(assigns)}
592 + type="button"
593 + phx-click="toggle_state"
594 + class="btn btn-sm btn-ghost"
595 + >
556 596 Close
557 597 </button>
558 598 </div>
added test/git_gud_web/live/pr_live/reopen_test.exs
+164 −0
@@ -0,0 +1,164 @@
1 +defmodule GitGudWeb.PrLive.ReopenTest do
2 + @moduledoc """
3 + Closing and reopening a pull request, and who is allowed to.
4 + """
5 +
6 + use GitGudWeb.ConnCase, async: false
7 +
8 + import Phoenix.LiveViewTest
9 + import GitGud.AccountsFixtures
10 + import GitGud.ForgeFixtures
11 +
12 + alias GitGud.Events
13 + alias GitGud.Organizations
14 + alias GitGud.PullRequests
15 + alias GitGud.Repositories
16 +
17 + defp pr_path(repo, pr) do
18 + handle = Repositories.Storage.repo_handle(GitGud.Repo.preload(repo, [:owner, :organization]))
19 + ~p"/r/#{handle}/#{repo.name}/pulls/#{pr.number}"
20 + end
21 +
22 + defp close!(pr) do
23 + {:ok, pr} = PullRequests.set_state(pr, "closed")
24 + pr
25 + end
26 +
27 + test "the author can reopen their own closed PR", %{conn: conn} do
28 + {user, repo} = repository_fixture()
29 + pr = pr_with_branches(repo, user) |> close!()
30 +
31 + {:ok, lv, html} = live(log_in_user(conn, user), pr_path(repo, pr))
32 + assert html =~ "This pull request is closed."
33 +
34 + lv |> element("button", "Reopen") |> render_click()
35 +
36 + assert PullRequests.get_pull_request!(repo, pr.number).state == "open"
37 + end
38 +
39 + test "a repo admin can reopen someone else's closed PR", %{conn: conn} do
40 + {owner, repo} = repository_fixture(%{visibility: "public"})
41 + author = user_fixture()
42 +
43 + pr =
44 + repo
45 + |> pr_with_branches(author)
46 + |> close!()
47 +
48 + {:ok, lv, _html} = live(log_in_user(conn, owner), pr_path(repo, pr))
49 + lv |> element("button", "Reopen") |> render_click()
50 +
51 + assert PullRequests.get_pull_request!(repo, pr.number).state == "open"
52 + end
53 +
54 + test "an org admin can reopen a PR in the org's repo", %{conn: conn} do
55 + admin = user_fixture()
56 +
57 + {:ok, org} =
58 + Organizations.create_organization(admin, %{
59 + "handle" => "reopen-org",
60 + "visibility" => "public"
61 + })
62 +
63 + {:ok, repo} =
64 + Repositories.create_repository_for_org(org, admin, %{
65 + "name" => "org-pr-repo",
66 + "visibility" => "public"
67 + })
68 +
69 + author = user_fixture()
70 + pr = pr_with_branches(repo, author) |> close!()
71 +
72 + {:ok, lv, _html} = live(log_in_user(conn, admin), pr_path(repo, pr))
73 + lv |> element("button", "Reopen") |> render_click()
74 +
75 + assert PullRequests.get_pull_request!(repo, pr.number).state == "open"
76 + end
77 +
78 + test "an unrelated user gets no reopen button", %{conn: conn} do
79 + {_owner, repo} = repository_fixture(%{visibility: "public"})
80 + author = user_fixture()
81 + pr = pr_with_branches(repo, author) |> close!()
82 +
83 + outsider = user_fixture()
84 + {:ok, lv, html} = live(log_in_user(conn, outsider), pr_path(repo, pr))
85 +
86 + assert html =~ "This pull request is closed."
87 + refute has_element?(lv, "button[phx-click=toggle_state]")
88 + end
89 +
90 + test "an unrelated user can't reopen by sending the event", %{conn: conn} do
91 + {_owner, repo} = repository_fixture(%{visibility: "public"})
92 + author = user_fixture()
93 + pr = pr_with_branches(repo, author) |> close!()
94 +
95 + outsider = user_fixture()
96 + {:ok, lv, _html} = live(log_in_user(conn, outsider), pr_path(repo, pr))
97 +
98 + render_hook(lv, "toggle_state", %{})
99 +
100 + assert PullRequests.get_pull_request!(repo, pr.number).state == "closed"
101 + end
102 +
103 + test "an anonymous visitor can't either", %{conn: conn} do
104 + {user, repo} = repository_fixture(%{visibility: "public"})
105 + pr = pr_with_branches(repo, user) |> close!()
106 +
107 + {:ok, lv, _html} = live(conn, pr_path(repo, pr))
108 + render_hook(lv, "toggle_state", %{})
109 +
110 + assert PullRequests.get_pull_request!(repo, pr.number).state == "closed"
111 + end
112 +
113 + test "reopening is recorded in the history", %{conn: conn} do
114 + {user, repo} = repository_fixture()
115 + pr = pr_with_branches(repo, user) |> close!()
116 +
117 + {:ok, lv, _html} = live(log_in_user(conn, user), pr_path(repo, pr))
118 + lv |> element("button", "Reopen") |> render_click()
119 +
120 + assert [%{kind: "reopened", actor: %{id: actor_id}}] = Events.list_for(pr)
121 + assert actor_id == user.id
122 +
123 + {:ok, _hist, html} = live(log_in_user(conn, user), pr_path(repo, pr) <> "/history")
124 + assert html =~ "reopened this."
125 + end
126 +
127 + test "a merged PR offers no reopen and refuses one", %{conn: conn} do
128 + {user, repo} = repository_fixture()
129 + pr = pr_with_branches(repo, user)
130 +
131 + {:ok, merged} = pr |> Ecto.Changeset.change(state: "merged") |> GitGud.Repo.update()
132 +
133 + {:ok, lv, html} = live(log_in_user(conn, user), pr_path(repo, merged))
134 +
135 + refute html =~ "Reopen"
136 + render_hook(lv, "toggle_state", %{})
137 +
138 + assert PullRequests.get_pull_request!(repo, pr.number).state == "merged"
139 + end
140 +
141 + test "set_state/2 refuses to reopen a merged PR at the context level" do
142 + {user, repo} = repository_fixture()
143 + pr = pr_with_branches(repo, user)
144 + {:ok, merged} = pr |> Ecto.Changeset.change(state: "merged") |> GitGud.Repo.update()
145 +
146 + assert {:error, :merged} = PullRequests.set_state(merged, "open")
147 + end
148 +
149 + test "an open PR still closes, and only for the author or an admin", %{conn: conn} do
150 + {_owner, repo} = repository_fixture(%{visibility: "public"})
151 + author = user_fixture()
152 + pr = pr_with_branches(repo, author)
153 +
154 + outsider = user_fixture()
155 + {:ok, lv, _html} = live(log_in_user(conn, outsider), pr_path(repo, pr))
156 + refute has_element?(lv, "button[phx-click=toggle_state]")
157 + render_hook(lv, "toggle_state", %{})
158 + assert PullRequests.get_pull_request!(repo, pr.number).state == "open"
159 +
160 + {:ok, lv, _html} = live(log_in_user(conn, author), pr_path(repo, pr))
161 + lv |> element("button", "Close") |> render_click()
162 + assert PullRequests.get_pull_request!(repo, pr.number).state == "closed"
163 + end
164 +end

Parents: 98604fb