Offer a prefilled pull request from the fork-ahead banner

5a2585d · Gabriel Morell · 2026-09-09 21:47

2 files +216 -0
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud_web/live/repo_live/show.ex
+16 −0
@@ -82,6 +82,15 @@ defmodule GitGudWeb.RepoLive.Show do
82 82 end
83 83 end
84 84
85 + # Both sides of the PR, prefilled from what the banner already knows:
86 + # this fork's branch into the same branch on the parent, which is the
87 + # comparison `fork_status/2` just made. The page is mounted on the
88 + # fork, so the parent is named by id — `PrLive.New` resolves it out of
89 + # the shared-lineage candidate pool rather than us having to load it.
90 + defp open_pr_link(handle, repo, fork_status) do
91 + ~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 + end
93 +
85 94 defp maybe_fork_status(%{parent_repository_id: nil}, _ref), do: nil
86 95
87 96 defp maybe_fork_status(repo, ref) do
@@ -155,6 +164,13 @@ defmodule GitGudWeb.RepoLive.Show do
155 164 Divergedsync isn't a fast-forward; resolve via PR or manual merge.
156 165 </p>
157 166 </div>
167 + <.link
168 + :if={@fork_status.ahead > 0 and @current_scope != nil and @current_scope.user != nil}
169 + navigate={open_pr_link(@handle, @repo, @fork_status)}
170 + class="btn btn-xs btn-primary"
171 + >
172 + <.icon name="hero-arrow-right-circle" class="size-4" /> Open pull request
173 + </.link>
158 174 <button
159 175 :if={@fork_status.fast_forward?}
160 176 phx-click="sync_fork"
added test/git_gud_web/live/repo_live/fork_banner_test.exs
+200 −0
@@ -0,0 +1,200 @@
1 +defmodule GitGudWeb.RepoLive.ForkBannerTest do
2 + @moduledoc """
3 + The fork-status banner on a repo page, and the prefilled
4 + "Open pull request" link it offers once the fork is ahead.
5 + """
6 +
7 + use GitGudWeb.ConnCase, async: false
8 +
9 + import Phoenix.LiveViewTest
10 + import GitGud.AccountsFixtures
11 + import GitGud.ForgeFixtures
12 +
13 + alias GitGud.Git
14 + alias GitGud.Repositories
15 +
16 + @moduletag :git_required
17 +
18 + setup do
19 + if System.find_executable("git") == nil do
20 + {:skip, "git not on PATH"}
21 + else
22 + :ok
23 + end
24 + end
25 +
26 + # An upstream with one commit, forked, with the fork one commit ahead
27 + # on main — the state the banner describes.
28 + defp fork_ahead_by_one do
29 + {_owner, upstream} = repository_fixture()
30 + {:ok, upstream} = Repositories.update_repository(upstream, %{"visibility" => "public"})
31 + seed_commit(upstream)
32 +
33 + forker = user_fixture()
34 + {:ok, fork} = Repositories.fork(upstream, forker, forker)
35 + {:ok, fork} = Repositories.update_repository(fork, %{"visibility" => "public"})
36 +
37 + advance_main(fork)
38 +
39 + {forker, upstream, fork}
40 + end
41 +
42 + defp fork_path(fork) do
43 + handle = Repositories.Storage.repo_handle(GitGud.Repo.preload(fork, [:owner, :organization]))
44 + ~p"/r/#{handle}/#{fork.name}"
45 + end
46 +
47 + test "an ahead fork offers a pull request link with both sides filled in", %{conn: conn} do
48 + {forker, upstream, fork} = fork_ahead_by_one()
49 +
50 + {:ok, _lv, html} = live(log_in_user(conn, forker), fork_path(fork))
51 +
52 + assert html =~ "commit(s) ahead"
53 + assert html =~ "Open pull request"
54 +
55 + # Source is this fork's branch, target the same branch on the parent.
56 + assert html =~ "source_repository_id=#{fork.id}"
57 + assert html =~ "target_repository_id=#{upstream.id}"
58 + assert html =~ "source=main"
59 + assert html =~ "target=main"
60 + end
61 +
62 + test "the link lands on a new-PR form already pointed at the upstream", %{conn: conn} do
63 + {forker, upstream, fork} = fork_ahead_by_one()
64 +
65 + {:ok, lv, _html} = live(log_in_user(conn, forker), fork_path(fork))
66 +
67 + {:ok, pr_lv, html} =
68 + lv
69 + |> element("a", "Open pull request")
70 + |> render_click()
71 + |> follow_redirect(log_in_user(conn, forker))
72 +
73 + assert html =~ "New pull request"
74 +
75 + # Both dropdowns land pre-selected — the base on the upstream, not
76 + # back on the fork. Asserting on `selected` rather than the option's
77 + # presence, since every candidate renders as an option either way.
78 + assert has_element?(
79 + pr_lv,
80 + ~s{select[name="pull_request[repository_id]"] option[value="#{upstream.id}"][selected]}
81 + )
82 +
83 + assert has_element?(
84 + pr_lv,
85 + ~s{select[name="pull_request[source_repository_id]"] option[value="#{fork.id}"][selected]}
86 + )
87 +
88 + assert has_element?(
89 + pr_lv,
90 + ~s{select[name="pull_request[source_ref]"] option[value="main"][selected]}
91 + )
92 +
93 + assert has_element?(
94 + pr_lv,
95 + ~s{select[name="pull_request[target_ref]"] option[value="main"][selected]}
96 + )
97 + end
98 +
99 + test "anonymous visitors see the banner but no link", %{conn: conn} do
100 + {_forker, _upstream, fork} = fork_ahead_by_one()
101 +
102 + {:ok, _lv, html} = live(conn, fork_path(fork))
103 +
104 + assert html =~ "commit(s) ahead"
105 + refute html =~ "Open pull request"
106 + end
107 +
108 + test "a fork that is only behind gets Sync, not a PR link", %{conn: conn} do
109 + {_owner, upstream} = repository_fixture()
110 + {:ok, upstream} = Repositories.update_repository(upstream, %{"visibility" => "public"})
111 + seed_commit(upstream)
112 +
113 + forker = user_fixture()
114 + {:ok, fork} = Repositories.fork(upstream, forker, forker)
115 + {:ok, fork} = Repositories.update_repository(fork, %{"visibility" => "public"})
116 +
117 + # Upstream moves on; the fork stays put.
118 + advance_main(upstream)
119 +
120 + {:ok, _lv, html} = live(log_in_user(conn, forker), fork_path(fork))
121 +
122 + assert html =~ "commit(s) behind upstream"
123 + assert html =~ "Sync from upstream"
124 + refute html =~ "Open pull request"
125 + end
126 +
127 + # ── git plumbing ─────────────────────────────────────────────────────
128 +
129 + defp seed_commit(repo) do
130 + blob_sha = write_blob(repo.disk_path, "hello\n")
131 + tree_sha = make_tree(repo.disk_path, nil, "README.md", blob_sha)
132 +
133 + {:ok, commit_sha} =
134 + Git.commit_tree(repo.disk_path, tree_sha, [], "init",
135 + author: {"t", "t@t", DateTime.utc_now(:second)}
136 + )
137 +
138 + :ok = Git.update_ref(repo.disk_path, "refs/heads/main", commit_sha, nil)
139 + commit_sha
140 + end
141 +
142 + defp advance_main(repo) do
143 + {:ok, main} = Git.resolve(repo.disk_path, "main")
144 + blob = write_blob(repo.disk_path, "more\n")
145 + tree = make_tree(repo.disk_path, main, "more.txt", blob)
146 +
147 + {:ok, new_sha} =
148 + Git.commit_tree(repo.disk_path, tree, [main], "advance",
149 + author: {"t", "t@t", DateTime.utc_now(:second)}
150 + )
151 +
152 + :ok = Git.update_ref(repo.disk_path, "refs/heads/main", new_sha, main)
153 + new_sha
154 + end
155 +
156 + defp write_blob(path, contents) do
157 + tmp = Path.join(System.tmp_dir!(), "fb-blob-#{System.unique_integer([:positive])}")
158 + File.write!(tmp, contents)
159 + {hex, 0} = System.cmd("git", ["-C", path, "hash-object", "-w", tmp])
160 + File.rm(tmp)
161 + {:ok, sha} = Git.from_hex(String.trim(hex))
162 + sha
163 + end
164 +
165 + defp make_tree(path, parent_sha, filename, blob_sha) do
166 + index = Path.join(System.tmp_dir!(), "fb-idx-#{System.unique_integer([:positive])}")
167 + env = [{"GIT_INDEX_FILE", index}]
168 +
169 + try do
170 + if parent_sha do
171 + {:ok, parent_commit} = Git.commit(path, parent_sha)
172 +
173 + {_, 0} =
174 + System.cmd("git", ["-C", path, "read-tree", Git.to_hex(parent_commit.tree_sha)],
175 + env: env
176 + )
177 + end
178 +
179 + {_, 0} =
180 + System.cmd(
181 + "git",
182 + [
183 + "-C",
184 + path,
185 + "update-index",
186 + "--add",
187 + "--cacheinfo",
188 + "100644," <> Git.to_hex(blob_sha) <> "," <> filename
189 + ],
190 + env: env
191 + )
192 +
193 + {hex, 0} = System.cmd("git", ["-C", path, "write-tree"], env: env)
194 + {:ok, tree_sha} = Git.from_hex(String.trim(hex))
195 + tree_sha
196 + after
197 + File.rm(index)
198 + end
199 + end
200 +end

Parents: 8a2b95a