Backlink cross-referenced issues and PRs in their history

8d95805 · Gabriel Morell · 2026-09-10 15:54

9 files +381 -1
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/events.ex
+67 −0
@@ -83,6 +83,73 @@ defmodule GitGud.Events do
83 83 Map.new(map, fn {k, v} -> {to_string(k), v} end)
84 84 end
85 85
86 + @doc """
87 + Record a backlink on everything newly cross-referenced by a body.
88 +
89 + `old_body` is what the text said before the editrefs already
90 + present then aren't new relationships, so re-saving a description
91 + doesn't re-announce the same link. Pass nil when the body is being
92 + created.
93 +
94 + A body referring to its own issue records nothing, and a target
95 + already backlinked from this source is left alone, so an edit that
96 + moves a reference around doesn't stack duplicates.
97 + """
98 + def record_cross_references(source, repo, old_body, new_body, actor) do
99 + previous = ref_keys(old_body, repo)
100 +
101 + for {kind, item, _target_repo} <- GitGud.Markdown.extract_refs(new_body || "", repo),
102 + {kind, item.id} not in previous,
103 + not same_target?(source, kind, item) do
104 + :ok = record_backlink(kind, item, source, repo, actor)
105 + end
106 +
107 + :ok
108 + end
109 +
110 + defp ref_keys(nil, _repo), do: []
111 +
112 + defp ref_keys(old_body, repo) do
113 + old_body
114 + |> GitGud.Markdown.extract_refs(repo)
115 + |> Enum.map(fn {kind, item, _} -> {kind, item.id} end)
116 + end
117 +
118 + # `lookup_referent/2` hands back a bare map, not the struct, so the
119 + # comparison is on type + id rather than struct identity.
120 + defp same_target?(%Issue{id: id}, :issue, %{id: id}), do: true
121 + defp same_target?(%PullRequest{id: id}, :pull_request, %{id: id}), do: true
122 + defp same_target?(_source, _kind, _item), do: false
123 +
124 + defp record_backlink(kind, item, source, source_repo, actor) do
125 + target_type = if kind == :issue, do: "issue", else: "pull_request"
126 +
127 + if already_backlinked?(target_type, item.id, source) do
128 + :ok
129 + else
130 + do_record(target_type, item.id, "cross_referenced", actor, %{
131 + from_type: source_kind(source),
132 + from_number: source.number,
133 + from_title: source.title,
134 + from_repo: GitGud.Repositories.Storage.repo_handle(source_repo) <> "/" <> source_repo.name
135 + })
136 + end
137 + end
138 +
139 + defp source_kind(%Issue{}), do: "issue"
140 + defp source_kind(%PullRequest{}), do: "pull_request"
141 +
142 + defp already_backlinked?(target_type, target_id, source) do
143 + from(e in Event,
144 + where:
145 + e.target_type == ^target_type and e.target_id == ^target_id and
146 + e.kind == "cross_referenced" and
147 + fragment("?->>'from_type' = ?", e.data, ^source_kind(source)) and
148 + fragment("?->>'from_number' = ?", e.data, ^to_string(source.number))
149 + )
150 + |> Repo.exists?()
151 + end
152 +
86 153 @doc "One target's history, oldest first, with `:actor` preloaded."
87 154 def list_for(target, opts \\ []) do
88 155 {type, id} = target_ref(target)
modified lib/git_gud/events/event.ex
+1 −0
@@ -14,6 +14,7 @@ defmodule GitGud.Events.Event do
14 14 moderated unmoderated
15 15 comment_added comment_edited comment_deleted
16 16 suggestion_applied
17 + cross_referenced
17 18 reviewed review_requested review_request_removed
18 19 )
19 20
modified lib/git_gud/markdown.ex
+49 −0
@@ -163,6 +163,55 @@ defmodule GitGud.Markdown do
163 163 end)
164 164 end
165 165
166 + @doc """
167 + The issues and pull requests `body` cross-references, resolved.
168 +
169 + Returns `{kind, item, repo}` tuplesthe same targets
170 + `expand_cross_refs/2` would turn into links, without rendering
171 + anything. Skips code spans and fences for the same reason the
172 + renderer does: a `#42` inside backticks isn't a reference.
173 +
174 + Deduplicated, since a body mentioning the same issue twice is still
175 + one relationship.
176 + """
177 + def extract_refs(body, repo) when is_binary(body) do
178 + body
179 + |> split_code_regions()
180 + |> Enum.flat_map(fn
181 + {:text, t} -> collect_refs(t, repo)
182 + {:code, _} -> []
183 + end)
184 + |> Enum.uniq_by(fn {kind, item, ref_repo} -> {kind, item.id, ref_repo.id} end)
185 + end
186 +
187 + def extract_refs(_body, _repo), do: []
188 +
189 + defp collect_refs(text, repo) do
190 + @ref_re
191 + |> Regex.scan(text, capture: :all_names)
192 + |> Enum.flat_map(fn captures ->
193 + # `capture: :all_names` yields the named groups alphabetically:
194 + # name, owner, ref.
195 + [name, owner, ref_str] = captures
196 +
197 + case parse_ref_number(ref_str) do
198 + :error ->
199 + []
200 +
201 + number ->
202 + target_repo =
203 + if owner != "" and name != "",
204 + do: GitGud.Repositories.find_by_path(owner, name),
205 + else: repo
206 +
207 + case target_repo && GitGud.Issues.lookup_referent(target_repo, number) do
208 + {kind, item} -> [{kind, item, target_repo}]
209 + _ -> []
210 + end
211 + end
212 + end)
213 + end
214 +
166 215 defp local_link(repo, ref_str, number, fallback) do
167 216 case GitGud.Issues.lookup_referent(repo, number) do
168 217 {kind, item} ->
modified lib/git_gud_web/components/event_components.ex
+15 −0
@@ -152,6 +152,7 @@ defmodule GitGudWeb.EventComponents do
152 152 defp dot_class(k) when k in ["reviewed", "review_requested"], do: "bg-accent"
153 153 defp dot_class("review_request_removed"), do: "bg-base-300"
154 154 defp dot_class("suggestion_applied"), do: "bg-success"
155 + defp dot_class("cross_referenced"), do: "bg-info"
155 156 defp dot_class(k) when k in ["comment_added", "comment_edited"], do: "bg-info"
156 157 defp dot_class("comment_deleted"), do: "bg-error"
157 158 defp dot_class("merged"), do: "bg-secondary"
@@ -245,6 +246,20 @@ defmodule GitGudWeb.EventComponents do
245 246
246 247 defp describe(%{kind: "suggestion_applied"}), do: "applied a suggestion."
247 248
249 + defp describe(%{kind: "cross_referenced", data: data}) do
250 + assigns = %{
251 + kind: if(data["from_type"] == "issue", do: "issue", else: "pull request"),
252 + repo: data["from_repo"],
253 + number: data["from_number"],
254 + title: data["from_title"]
255 + }
256 +
257 + ~H"""
258 + referenced this from {@kind}
259 + <span class="font-mono text-xs">{@repo}#{@number}</span><span :if={@title}> — {@title}</span>.
260 + """
261 + end
262 +
248 263 defp describe(%{kind: "comment_added"}), do: "commented."
249 264 defp describe(%{kind: "comment_edited"}), do: "edited a comment."
250 265 defp describe(%{kind: "comment_deleted"}), do: "deleted a comment."
modified lib/git_gud_web/live/issue_live/new.ex
+11 −0
@@ -109,6 +109,17 @@ defmodule GitGudWeb.IssueLive.New do
109 109 _ = Issues.attach_label(issue, Labels.get_label!(lid))
110 110 end)
111 111
112 + # A reference written while opening the issue is as much a link
113 + # as one added later.
114 + :ok =
115 + GitGud.Events.record_cross_references(
116 + issue,
117 + socket.assigns.repo,
118 + nil,
119 + issue.body,
120 + user
121 + )
122 +
112 123 {:noreply,
113 124 socket
114 125 |> put_flash(:info, "Issue ##{issue.number} created.")
modified lib/git_gud_web/live/issue_live/show.ex
+9 −0
@@ -297,6 +297,15 @@ defmodule GitGudWeb.IssueLive.Show do
297 297 to: updated.body || ""
298 298 })
299 299
300 + :ok =
301 + Events.record_cross_references(
302 + updated,
303 + socket.assigns.repo,
304 + target.body,
305 + updated.body,
306 + viewer(socket)
307 + )
308 +
300 309 {:noreply,
301 310 socket
302 311 |> assign(:editing_body?, false)
modified lib/git_gud_web/live/pr_live/new.ex
+10 −1
@@ -196,7 +196,16 @@ defmodule GitGudWeb.PrLive.New do
196 196 case PullRequests.create_pull_request(target, user, attrs) do
197 197 {:ok, pr} ->
198 198 # The PR belongs to the base repo, which needn't be the one we
199 # started from.
199 + # started from — and that's the repo a bare `#42` resolves in.
200 + :ok =
201 + GitGud.Events.record_cross_references(
202 + pr,
203 + target,
204 + nil,
205 + pr.body,
206 + socket.assigns.current_scope.user
207 + )
208 +
200 209 {:noreply,
201 210 socket
202 211 |> put_flash(:info, "Pull request ##{pr.number} opened.")
modified lib/git_gud_web/live/pr_live/show.ex
+9 −0
@@ -423,6 +423,15 @@ defmodule GitGudWeb.PrLive.Show do
423 423 to: updated.body || ""
424 424 })
425 425
426 + :ok =
427 + Events.record_cross_references(
428 + updated,
429 + socket.assigns.repo,
430 + target.body,
431 + updated.body,
432 + viewer(socket)
433 + )
434 +
426 435 {:noreply,
427 436 socket
428 437 |> assign(:editing_body?, false)
added test/git_gud_web/live/cross_reference_test.exs
+210 −0
@@ -0,0 +1,210 @@
1 +defmodule GitGudWeb.CrossReferenceTest do
2 + @moduledoc """
3 + Linking to another issue or PR leaves a backlink in that item's
4 + history, so the reference is visible from both ends.
5 + """
6 +
7 + use GitGudWeb.ConnCase, async: false
8 +
9 + import Phoenix.LiveViewTest
10 + import GitGud.ForgeFixtures
11 +
12 + alias GitGud.Events
13 + alias GitGud.Issues
14 + alias GitGud.Markdown
15 + alias GitGud.Repositories
16 +
17 + defp handle(repo),
18 + do: Repositories.Storage.repo_handle(GitGud.Repo.preload(repo, [:owner, :organization]))
19 +
20 + defp issue_path(repo, issue),
21 + do: ~p"/r/#{handle(repo)}/#{repo.name}/issues/#{issue.number}"
22 +
23 + defp edit_body(conn, user, repo, issue, body) do
24 + {:ok, lv, _html} = live(log_in_user(conn, user), issue_path(repo, issue))
25 + _ = lv |> element("button[phx-click=edit_body]") |> render_click()
26 + lv |> form("form[phx-submit=save_body]", %{body: body}) |> render_submit()
27 + end
28 +
29 + describe "extract_refs/2" do
30 + test "finds a local reference", %{conn: _conn} do
31 + {user, repo} = repository_fixture()
32 + target = issue_fixture(repo, user, %{"title" => "The target"})
33 +
34 + assert [{:issue, item, ^repo}] = Markdown.extract_refs("see ##{target.number}", repo)
35 + assert item.number == target.number
36 + end
37 +
38 + test "ignores references inside code", %{conn: _conn} do
39 + {user, repo} = repository_fixture()
40 + target = issue_fixture(repo, user)
41 +
42 + assert Markdown.extract_refs("`##{target.number}`", repo) == []
43 + assert Markdown.extract_refs("```\n##{target.number}\n```", repo) == []
44 + end
45 +
46 + test "ignores a number that resolves to nothing", %{conn: _conn} do
47 + {_user, repo} = repository_fixture()
48 + assert Markdown.extract_refs("see #9999", repo) == []
49 + end
50 +
51 + test "deduplicates repeated mentions", %{conn: _conn} do
52 + {user, repo} = repository_fixture()
53 + target = issue_fixture(repo, user)
54 +
55 + body = "##{target.number} and again ##{target.number}"
56 + assert [_one] = Markdown.extract_refs(body, repo)
57 + end
58 +
59 + test "resolves a cross-repo reference", %{conn: _conn} do
60 + {user_a, repo_a} = repository_fixture(%{visibility: "public"})
61 + target = issue_fixture(repo_a, user_a, %{"title" => "Over there"})
62 +
63 + {_user_b, repo_b} = repository_fixture(%{visibility: "public"})
64 + body = "see #{handle(repo_a)}/#{repo_a.name}##{target.number}"
65 +
66 + assert [{:issue, item, found_repo}] = Markdown.extract_refs(body, repo_b)
67 + assert item.number == target.number
68 + assert found_repo.id == repo_a.id
69 + end
70 + end
71 +
72 + describe "backlinks" do
73 + test "editing a description backlinks the referenced issue", %{conn: conn} do
74 + {user, repo} = repository_fixture()
75 + target = issue_fixture(repo, user, %{"title" => "The target"})
76 + source = issue_fixture(repo, user, %{"title" => "The source", "body" => "nothing yet"})
77 +
78 + _ = edit_body(conn, user, repo, source, "fixes ##{target.number}")
79 +
80 + assert [%{kind: "cross_referenced", data: data}] = Events.list_for(target)
81 + assert data["from_number"] == source.number
82 + assert data["from_title"] == "The source"
83 + assert data["from_type"] == "issue"
84 + end
85 +
86 + test "the backlink shows on the target's history page", %{conn: conn} do
87 + {user, repo} = repository_fixture()
88 + target = issue_fixture(repo, user)
89 + source = issue_fixture(repo, user, %{"title" => "Mentions it", "body" => ""})
90 +
91 + _ = edit_body(conn, user, repo, source, "see ##{target.number}")
92 +
93 + {:ok, _hist, html} =
94 + live(log_in_user(conn, user), issue_path(repo, target) <> "/history")
95 +
96 + assert html =~ "referenced this from issue"
97 + assert html =~ "Mentions it"
98 + assert html =~ "##{source.number}"
99 + end
100 +
101 + test "a reference already present isn't re-announced on re-save", %{conn: conn} do
102 + {user, repo} = repository_fixture()
103 + target = issue_fixture(repo, user)
104 + source = issue_fixture(repo, user, %{"body" => "see ##{target.number}"})
105 +
106 + # Same reference, different surrounding prose.
107 + _ = edit_body(conn, user, repo, source, "still see ##{target.number}")
108 +
109 + assert Events.list_for(target) == []
110 + end
111 +
112 + test "an edit that adds a second reference backlinks only the new one", %{conn: conn} do
113 + {user, repo} = repository_fixture()
114 + first = issue_fixture(repo, user)
115 + second = issue_fixture(repo, user)
116 + source = issue_fixture(repo, user, %{"body" => "see ##{first.number}"})
117 +
118 + _ = edit_body(conn, user, repo, source, "see ##{first.number} and ##{second.number}")
119 +
120 + assert Events.list_for(first) == []
121 + assert [%{kind: "cross_referenced"}] = Events.list_for(second)
122 + end
123 +
124 + test "referencing the same target twice over two edits records once", %{conn: conn} do
125 + {user, repo} = repository_fixture()
126 + target = issue_fixture(repo, user)
127 + source = issue_fixture(repo, user, %{"body" => ""})
128 +
129 + _ = edit_body(conn, user, repo, source, "see ##{target.number}")
130 + _ = edit_body(conn, user, repo, source, "dropped it")
131 + _ = edit_body(conn, user, repo, source, "see ##{target.number} again")
132 +
133 + assert [_one] = Events.list_for(target)
134 + end
135 +
136 + test "an issue referring to itself records nothing", %{conn: conn} do
137 + {user, repo} = repository_fixture()
138 + source = issue_fixture(repo, user, %{"body" => ""})
139 +
140 + _ = edit_body(conn, user, repo, source, "this is ##{source.number}")
141 +
142 + # The edit itself is recorded; the self-reference isn't.
143 + kinds = source |> Events.list_for() |> Enum.map(& &1.kind)
144 + assert kinds == ["description_changed"]
145 + end
146 +
147 + test "opening an issue with a reference backlinks it", %{conn: conn} do
148 + {user, repo} = repository_fixture()
149 + target = issue_fixture(repo, user, %{"title" => "Pre-existing"})
150 +
151 + {:ok, lv, _html} =
152 + live(log_in_user(conn, user), ~p"/r/#{handle(repo)}/#{repo.name}/issues/new")
153 +
154 + _ =
155 + lv
156 + |> form("form[phx-submit=save]", %{
157 + "issue" => %{"title" => "Brand new", "body" => "relates to ##{target.number}"}
158 + })
159 + |> render_submit()
160 +
161 + assert [%{kind: "cross_referenced", data: data}] = Events.list_for(target)
162 + assert data["from_title"] == "Brand new"
163 + end
164 +
165 + test "a reference in code doesn't backlink", %{conn: conn} do
166 + {user, repo} = repository_fixture()
167 + target = issue_fixture(repo, user)
168 + source = issue_fixture(repo, user, %{"body" => ""})
169 +
170 + _ = edit_body(conn, user, repo, source, "the literal `##{target.number}` in code")
171 +
172 + assert Events.list_for(target) == []
173 + end
174 +
175 + test "a PR referenced from an issue is backlinked too", %{conn: conn} do
176 + {user, repo} = repository_fixture()
177 + pr = pr_with_branches(repo, user, %{"title" => "The PR"})
178 + source = issue_fixture(repo, user, %{"body" => ""})
179 +
180 + _ = edit_body(conn, user, repo, source, "implemented in ##{pr.number}")
181 +
182 + assert [%{kind: "cross_referenced", data: data}] = Events.list_for(pr)
183 + assert data["from_type"] == "issue"
184 + end
185 +
186 + test "the backlink names the source repo", %{conn: conn} do
187 + {user, repo} = repository_fixture()
188 + target = issue_fixture(repo, user)
189 + source = issue_fixture(repo, user, %{"body" => ""})
190 +
191 + _ = edit_body(conn, user, repo, source, "see ##{target.number}")
192 +
193 + assert [%{data: data}] = Events.list_for(target)
194 + assert data["from_repo"] == "#{handle(repo)}/#{repo.name}"
195 + end
196 +
197 + test "the source's own history is untouched", %{conn: conn} do
198 + {user, repo} = repository_fixture()
199 + target = issue_fixture(repo, user)
200 + source = issue_fixture(repo, user, %{"body" => ""})
201 +
202 + _ = edit_body(conn, user, repo, source, "see ##{target.number}")
203 +
204 + kinds = source |> Events.list_for() |> Enum.map(& &1.kind)
205 + # The edit itself is recorded; the backlink belongs to the target.
206 + assert kinds == ["description_changed"]
207 + assert Issues.get_issue!(repo, source.number).body =~ "##{target.number}"
208 + end
209 + end
210 +end

Parents: 4d4bd06