neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Let the author or a maintainer reopen a closed pull request
51e1414 · Gabriel Morell · 2026-09-09 22:38
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