Link any open PR from the fork-ahead banner

bd60fcb · Gabriel Morell · 2026-09-10 15:29

3 files +108 -2
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/pull_requests.ex
+18 −0
@@ -95,6 +95,24 @@ defmodule GitGud.PullRequests do
95 95 |> Repo.all()
96 96 end
97 97
98 + @doc """
99 + Open PRs already proposing `branch` from `source_repo`.
100 +
101 + Lives on whichever repo each PR targets, not on the source, so the
102 + rows come back with `:repository` loaded for linking.
103 + """
104 + def list_open_from(%Repository{id: sid}, branch) when is_binary(branch) do
105 + from(p in PullRequest,
106 + join: r in assoc(p, :repository),
107 + where:
108 + p.source_repository_id == ^sid and p.source_ref == ^branch and
109 + p.state == "open",
110 + order_by: [asc: p.number],
111 + preload: [repository: {r, [:owner, :organization]}]
112 + )
113 + |> Repo.all()
114 + end
115 +
98 116 @doc "How many pull requests are open against this repo."
99 117 def count_open(%Repository{id: rid}) do
100 118 PullRequest
modified lib/git_gud_web/live/repo_live/show.ex
+30 −1
@@ -45,7 +45,13 @@ defmodule GitGudWeb.RepoLive.Show do
45 45 |> assign(:tags, Enum.filter(refs, &(&1.ref_type == "tag")))
46 46 |> assign(:entries, entries)
47 47 |> assign(:readme, readme)
48 |> assign(:fork_status, maybe_fork_status(repo, ref))
48 + |> then(fn socket ->
49 + status = maybe_fork_status(repo, ref)
50 +
51 + socket
52 + |> assign(:fork_status, status)
53 + |> assign(:fork_prs, fork_prs(repo, status))
54 + end)
49 55 |> assign(:language_stats, load_language_stats(repo, sha))
50 56
51 57 {:error, _} ->
@@ -58,6 +64,7 @@ defmodule GitGudWeb.RepoLive.Show do
58 64 |> assign(:entries, [])
59 65 |> assign(:readme, nil)
60 66 |> assign(:fork_status, nil)
67 + |> assign(:fork_prs, [])
61 68 |> assign(:language_stats, nil)
62 69 end
63 70 end
@@ -91,6 +98,18 @@ defmodule GitGudWeb.RepoLive.Show do
91 98 ~p"/r/#{handle}/#{repo.name}/pulls/new?#{[source_repository_id: repo.id, source: fork_status.branch, target_repository_id: repo.parent_repository_id, target: fork_status.branch]}"
92 99 end
93 100
101 + @doc false
102 + # Public so the template can reach it; a PR's repo is a different one
103 + # than the page's, so `@handle` doesn't apply.
104 + def pr_handle(repo), do: Storage.repo_handle(repo)
105 +
106 + # An open PR already proposing this branch — so the banner can point
107 + # at it instead of only offering to open another.
108 + defp fork_prs(_repo, nil), do: []
109 +
110 + defp fork_prs(repo, %{branch: branch}),
111 + do: GitGud.PullRequests.list_open_from(repo, branch)
112 +
94 113 defp maybe_fork_status(%{parent_repository_id: nil}, _ref), do: nil
95 114
96 115 defp maybe_fork_status(repo, ref) do
@@ -164,6 +183,16 @@ defmodule GitGudWeb.RepoLive.Show do
164 183 Divergedsync isn't a fast-forward; resolve via PR or manual merge.
165 184 </p>
166 185 </div>
186 + <.link
187 + :for={pr <- @fork_prs}
188 + navigate={
189 + ~p"/r/#{GitGudWeb.RepoLive.Show.pr_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"
190 + }
191 + class="btn btn-xs btn-ghost border border-base-300"
192 + title={pr.title}
193 + >
194 + <.icon name="hero-arrow-top-right-on-square" class="size-3" /> #{pr.number}
195 + </.link>
167 196 <.link
168 197 :if={@fork_status.ahead > 0 and @current_scope != nil and @current_scope.user != nil}
169 198 navigate={open_pr_link(@handle, @repo, @fork_status)}
modified test/git_gud_web/live/repo_live/fork_banner_test.exs
+60 −1
@@ -95,6 +95,66 @@ defmodule GitGudWeb.RepoLive.ForkBannerTest do
95 95 )
96 96 end
97 97
98 + test "an existing open PR is linked alongside the new-PR button", %{conn: conn} do
99 + {forker, upstream, fork} = fork_ahead_by_one()
100 +
101 + pr =
102 + GitGud.ForgeFixtures.pull_request_fixture(upstream, forker, "main", "main", %{
103 + "title" => "Already proposed",
104 + "source_repository_id" => fork.id
105 + })
106 +
107 + {:ok, lv, html} = live(log_in_user(conn, forker), fork_path(fork))
108 +
109 + # Both routes out of the banner are offered.
110 + assert html =~ "Open pull request"
111 + assert has_element?(lv, "a", "##{pr.number}")
112 +
113 + # The link points at the PR on the upstream repo, not the fork.
114 + upstream_handle =
115 + Repositories.Storage.repo_handle(GitGud.Repo.preload(upstream, [:owner, :organization]))
116 +
117 + assert has_element?(
118 + lv,
119 + ~s{a[href="/r/#{upstream_handle}/#{upstream.name}/pulls/#{pr.number}"]}
120 + )
121 + end
122 +
123 + test "a closed PR is not linked", %{conn: conn} do
124 + {forker, upstream, fork} = fork_ahead_by_one()
125 +
126 + pr =
127 + GitGud.ForgeFixtures.pull_request_fixture(upstream, forker, "main", "main", %{
128 + "title" => "Closed one",
129 + "source_repository_id" => fork.id
130 + })
131 +
132 + {:ok, _} = GitGud.PullRequests.set_state(pr, "closed")
133 +
134 + {:ok, lv, _html} = live(log_in_user(conn, forker), fork_path(fork))
135 +
136 + refute has_element?(lv, "a", "##{pr.number}")
137 + end
138 +
139 + test "a PR from a different branch is not linked", %{conn: conn} do
140 + {forker, upstream, fork} = fork_ahead_by_one()
141 +
142 + # Give the fork another branch and propose that one instead.
143 + advance_branch(fork, "main", forker)
144 + {:ok, tip} = GitGud.Git.resolve(fork.disk_path, "main")
145 + :ok = GitGud.Git.update_ref(fork.disk_path, "refs/heads/other", tip, nil)
146 +
147 + pr =
148 + GitGud.ForgeFixtures.pull_request_fixture(upstream, forker, "other", "main", %{
149 + "title" => "Different branch",
150 + "source_repository_id" => fork.id
151 + })
152 +
153 + {:ok, lv, _html} = live(log_in_user(conn, forker), fork_path(fork))
154 +
155 + refute has_element?(lv, "a", "##{pr.number}")
156 + end
157 +
98 158 test "anonymous visitors see the banner but no link", %{conn: conn} do
99 159 {_forker, _upstream, fork} = fork_ahead_by_one()
100 160
@@ -122,5 +182,4 @@ defmodule GitGudWeb.RepoLive.ForkBannerTest do
122 182 assert html =~ "Sync from upstream"
123 183 refute html =~ "Open pull request"
124 184 end
125
126 185 end

Parents: e5de06e