neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Link any open PR from the fork-ahead banner
bd60fcb · Gabriel Morell · 2026-09-10 15:29
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 | Diverged — sync 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