Add Open Graph cards for pull requests and documentation pages

0c8b4190f9f2 · Devin AI · · parent 8a671b61d0a6

Add Open Graph cards for pull requests and documentation pages

Pull request pages and /docs pages previously fell back to the generic
site card. Both now emit the same signed, content-versioned card images
that repositories, issues, commits, and blobs already use.

The pull request card shows the title, author, head and base branches,
a state chip (open, draft, merged, closed), and the stack position when
the pull request belongs to a stack. The docs card shows the section,
page title, opening paragraph, described route, and heading count.

Docs cards live under /og/v/:version/docs/:slug; everything else stays
under the repos scope, and existing card URLs are unchanged.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents_web/controllers/og_image_controller.ex
  • modified lib/openagents_web/live/docs_live.ex
  • modified lib/openagents_web/live/pull_request_show_live.ex
  • modified lib/openagents_web/og.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents_web/controllers/og_image_controller_test.exs
  • modified test/openagents_web/og_test.exs

Diff

7 files changed, +307 -7

lib/openagents_web/controllers/og_image_controller.ex modified +46 -2

@@ -25,8 +25,10 @@ defmodule OpenAgentsWeb.OgImageController do

25 25
  alias OpenAgents.Forge
26 26
  alias OpenAgents.Forge.Browse
27 27
  alias OpenAgents.Issues
28
  alias OpenAgents.PullRequests
28 29
  alias OpenAgents.Repositories
29
  alias OpenAgentsWeb.{OG, RepositoryAccess}
30
  alias OpenAgents.Stacks
31
  alias OpenAgentsWeb.{DocsCatalog, OG, RepositoryAccess}
30 32
31 33
  @cache_control "public, max-age=21600, immutable"
32 34
  @not_found_cache_control "public, max-age=60"

@@ -65,6 +67,35 @@ defmodule OpenAgentsWeb.OgImageController do

65 67
    end)
66 68
  end
67 69
70
  def pull(conn, params) do
71
    authorize_and_run(conn, params, fn owner, name ->
72
      with {number, ""} <- Integer.parse(strip_png(params["number"])),
73
           {:ok, repository} <- public_repository(owner, name),
74
           %PullRequests.PullRequest{} = pull_request <-
75
             safe(fn -> PullRequests.get_by_number!(repository, number) end) do
76
        {position, size} = stack_placement(repository, pull_request)
77
78
        OG.pull_request(owner, name, pull_request,
79
          stack_position: position,
80
          stack_size: size
81
        )
82
      else
83
        _error -> :error
84
      end
85
    end)
86
  end
87
88
  # Documentation is public by construction: the catalog is the compile-time
89
  # allowlist, so a slug it cannot render is the same 404 as a bad signature.
90
  def docs(conn, params) do
91
    authorized(conn, fn ->
92
      case DocsCatalog.render(strip_png(params["slug"]) || "") do
93
        {:ok, page} -> OG.docs(page)
94
        :error -> :error
95
      end
96
    end)
97
  end
98
68 99
  def commit(conn, params) do
69 100
    authorize_and_run(conn, params, fn owner, name ->
70 101
      sha = strip_png(params["sha"])

@@ -118,8 +149,14 @@ defmodule OpenAgentsWeb.OgImageController do

118 149
  # One response for every refusal — bad signature, unknown repository,
119 150
  # private repository, missing resource — so none of them can be told apart.
120 151
  defp authorize_and_run(conn, params, build) do
152
    authorized(conn, fn ->
153
      build.(strip_png(params["owner"]), strip_png(params["repo"]))
154
    end)
155
  end
156
157
  defp authorized(conn, build) do
121 158
    if OG.valid_signature?(conn.request_path, conn.query_params["sig"]) do
122
      case build.(strip_png(params["owner"]), strip_png(params["repo"])) do
159
      case build.() do
123 160
        %OG{} = card -> respond_with_card(conn, card)
124 161
        _refused -> not_found(conn)
125 162
      end

@@ -165,6 +202,13 @@ defmodule OpenAgentsWeb.OgImageController do

165 202
166 203
  ## Helpers -------------------------------------------------------------------
167 204
205
  defp stack_placement(repository, pull_request) do
206
    case safe(fn -> Stacks.review_context(repository, pull_request) end) do
207
      {:ok, context} -> {context.position, context.size}
208
      _other -> {nil, nil}
209
    end
210
  end
211
168 212
  defp public_repository(owner, name) do
169 213
    case safe(fn -> Repositories.get_public_by_path!(owner, name) end) do
170 214
      %Repositories.Repository{} = repository -> {:ok, repository}
lib/openagents_web/live/docs_live.ex modified +3

@@ -14,6 +14,7 @@ defmodule OpenAgentsWeb.DocsLive do

14 14
  use OpenAgentsWeb, :live_view
15 15
16 16
  alias OpenAgentsWeb.DocsCatalog
17
  alias OpenAgentsWeb.OG
17 18
18 19
  @impl true
19 20
  def mount(_params, _session, socket) do

@@ -27,6 +28,7 @@ defmodule OpenAgentsWeb.DocsLive do

27 28
     |> assign(:page_title, "Docs")
28 29
     |> assign(:active_page, :index)
29 30
     |> assign(:section_title, nil)
31
     |> assign(:og, nil)
30 32
     |> assign(:page, nil)}
31 33
  end
32 34

@@ -38,6 +40,7 @@ defmodule OpenAgentsWeb.DocsLive do

38 40
         |> assign(:page_title, page.item.title)
39 41
         |> assign(:active_page, page.item.slug)
40 42
         |> assign(:section_title, DocsCatalog.section_title(page.item.slug))
43
         |> assign(:og, OG.meta(OG.docs(page)))
41 44
         |> assign(:page, page)}
42 45
43 46
      :error ->
lib/openagents_web/live/pull_request_show_live.ex modified +10

@@ -17,6 +17,7 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

17 17
  alias OpenAgents.Repositories
18 18
  alias OpenAgents.Stacks
19 19
  alias OpenAgents.Stacks.Restack
20
  alias OpenAgentsWeb.OG
20 21
  alias OpenAgentsWeb.RepositoryAccess
21 22
22 23
  def mount(%{"owner" => owner, "repo" => repo, "number" => number}, _session, socket) do

@@ -38,6 +39,15 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

38 39
     |> assign(:pull_request, pull_request)
39 40
     |> assign(:stack_context, stack_context)
40 41
     |> assign(:stack_operation, nil)
42
     |> assign(
43
       :og,
44
       OG.meta(
45
         OG.pull_request(repository.namespace.slug, repository.name, pull_request,
46
           stack_position: stack_context && stack_context.position,
47
           stack_size: stack_context && stack_context.size
48
         )
49
       )
50
     )
41 51
     |> assign(
42 52
       :can_write,
43 53
       Repositories.writable?(repository, socket.assigns.current_user)
lib/openagents_web/og.ex modified +107 -5

@@ -28,12 +28,13 @@ defmodule OpenAgentsWeb.OG do

28 28
    # Canonical page URL this card describes (for og:url), e.g.
29 29
    # "/OpenAgentsInc/openagents.com/issues/12".
30 30
    page_path: nil,
31
    # Path segments under "/og/v/{version}/repos/", e.g.
32
    # ["Owner", "repo"] or ["Owner", "repo", "blob", "main", "lib/a.ex"].
31
    # Path segments under "/og/v/{version}/{scope}/", where the scope is
32
    # "repos" for repository resources and "docs" for documentation pages,
33
    # e.g. ["Owner", "repo", "blob", "main", "lib/a.ex"] or ["stacks-api"].
33 34
    path_suffix: []
34 35
  ]
35 36
36
  @type kind :: :site | :repo | :issue | :blob | :commit
37
  @type kind :: :site | :repo | :issue | :pull | :blob | :commit | :docs
37 38
38 39
  @type t :: %__MODULE__{
39 40
          kind: kind(),

@@ -122,6 +123,101 @@ defmodule OpenAgentsWeb.OG do

122 123
    }
123 124
  end
124 125
126
  @doc """
127
  A pull request card: state, title, branches, and stack position. `opts`
128
  accepts `:stack_position` and `:stack_size`; an unstacked pull request
129
  simply shows no stack chip.
130
  """
131
  def pull_request(repository_owner, repository_name, pull_request, opts \\ [])
132
      when is_list(opts) do
133
    issue = pull_request.issue
134
    number_string = Integer.to_string(issue.number)
135
    author = author_login(issue)
136
137
    chips =
138
      [%{label: pull_state_label(pull_request), tone: pull_state_tone(pull_request)}] ++
139
        stack_chip(opts[:stack_position], opts[:stack_size])
140
141
    %__MODULE__{
142
      kind: :pull,
143
      kicker: "#{repository_owner}/#{repository_name} · ##{number_string}",
144
      heading: issue.title || "(no title)",
145
      description: "#{pull_request.head_ref} → #{pull_request.base_ref}",
146
      avatar: author,
147
      chips: chips,
148
      stats:
149
        clean_stats([
150
          opt_stat(author),
151
          dated_stat("Opened", issue.inserted_at),
152
          comments_stat(issue.comments)
153
        ]),
154
      title: meta_title("#{issue.title || "(no title)"} · Pull request ##{number_string}"),
155
      page_path: "/#{repository_owner}/#{repository_name}/pulls/#{number_string}",
156
      path_suffix: [repository_owner, repository_name, "pulls", number_string]
157
    }
158
  end
159
160
  @doc """
161
  A documentation page card, built from an `OpenAgentsWeb.DocsCatalog.render/1`
162
  page: section, title, the page's opening paragraph, and how much is on it.
163
  """
164
  def docs(page) when is_map(page) do
165
    item = page.item
166
    section = OpenAgentsWeb.DocsCatalog.section_title(item.slug)
167
168
    %__MODULE__{
169
      kind: :docs,
170
      kicker: if(section, do: "OpenAgents docs · #{section}", else: "OpenAgents docs"),
171
      heading: item.title,
172
      description: docs_summary(page[:markdown]),
173
      chips: [%{label: "Documentation"}],
174
      stats:
175
        clean_stats([
176
          opt_stat(item.route),
177
          headings_stat(page[:toc])
178
        ]),
179
      title: meta_title("#{item.title} · OpenAgents docs"),
180
      page_path: "/docs/#{item.slug}",
181
      path_suffix: [item.slug]
182
    }
183
  end
184
185
  # The opening paragraph of the Markdown source, flattened for a card:
186
  # headings skipped, inline links reduced to their text, code ticks dropped.
187
  defp docs_summary(markdown) when is_binary(markdown) do
188
    markdown
189
    |> String.split("\n")
190
    |> Enum.drop_while(&(String.starts_with?(&1, "#") or String.trim(&1) == ""))
191
    |> Enum.take_while(&(String.trim(&1) != ""))
192
    |> Enum.join(" ")
193
    |> String.replace(~r/\[([^\]]*)\]\([^)]*\)/, "\\1")
194
    |> String.replace("`", "")
195
    |> String.replace(~r/\s+/, " ")
196
    |> present()
197
  end
198
199
  defp docs_summary(_markdown), do: nil
200
201
  defp headings_stat(toc) when is_list(toc) and toc != [],
202
    do: plural(length(toc), "section", "sections")
203
204
  defp headings_stat(_toc), do: nil
205
206
  defp stack_chip(position, size) when is_integer(position) and is_integer(size),
207
    do: [%{label: "Stack layer #{position} of #{size}"}]
208
209
  defp stack_chip(_position, _size), do: []
210
211
  defp pull_state_label(%{merged_at: %DateTime{}}), do: "Merged"
212
  defp pull_state_label(%{state: "closed"}), do: "Closed"
213
  defp pull_state_label(%{draft: true}), do: "Draft"
214
  defp pull_state_label(_pull_request), do: "Open"
215
216
  defp pull_state_tone(%{merged_at: %DateTime{}}), do: :done
217
  defp pull_state_tone(%{state: "closed"}), do: :muted
218
  defp pull_state_tone(%{draft: true}), do: :muted
219
  defp pull_state_tone(_pull_request), do: :open
220
125 221
  @doc """
126 222
  A file card — the layer GitHub does not have. `info` accepts `:ref`,
127 223
  `:size`, `:lines`, and `:truncated`; anything missing drops its stat.

@@ -299,17 +395,23 @@ defmodule OpenAgentsWeb.OG do

299 395
  @doc """
300 396
  The canonical request path for a card, versioned by its content digest:
301 397
302
      /og/v/{version}/repos/{owner}/{repo}[/{rest}].png
398
      /og/v/{version}/{scope}/{suffix...}.png
399
400
  where the scope is `repos` for repository resources and `docs` for
401
  documentation pages.
303 402
  """
304 403
  def request_path(%__MODULE__{} = card) do
305 404
    version = version(card)
306 405
307 406
    segments =
308
      ["og", "v", version, "repos" | Enum.map(card.path_suffix, &path_segment/1)]
407
      ["og", "v", version, scope_segment(card.kind) | Enum.map(card.path_suffix, &path_segment/1)]
309 408
310 409
    "/" <> Enum.join(segments, "/") <> ".png"
311 410
  end
312 411
412
  defp scope_segment(:docs), do: "docs"
413
  defp scope_segment(_kind), do: "repos"
414
313 415
  defp path_segment(value), do: URI.encode(value, &URI.char_unreserved?/1)
314 416
315 417
  @doc "Content digest over exactly what the template will draw."
lib/openagents_web/router.ex modified +2

@@ -640,8 +640,10 @@ defmodule OpenAgentsWeb.Router do

640 640
    get "/static/card.png", OgImageController, :static
641 641
    get "/v/:version/repos/:owner/:repo", OgImageController, :repo
642 642
    get "/v/:version/repos/:owner/:repo/issues/:number", OgImageController, :issue
643
    get "/v/:version/repos/:owner/:repo/pulls/:number", OgImageController, :pull
643 644
    get "/v/:version/repos/:owner/:repo/commit/:sha", OgImageController, :commit
644 645
    get "/v/:version/repos/:owner/:repo/blob/:ref/*path", OgImageController, :blob
646
    get "/v/:version/docs/:slug", OgImageController, :docs
645 647
  end
646 648
647 649
  # Keep repository-shaped routes last. Every fixed product, API, operator,
test/openagents_web/controllers/og_image_controller_test.exs modified +63

@@ -9,7 +9,10 @@ defmodule OpenAgentsWeb.OgImageControllerTest do

9 9
10 10
  alias OpenAgents.Forge.Repos
11 11
  alias OpenAgents.Issues
12
  alias OpenAgents.PullRequests.PullRequest
13
  alias OpenAgents.Repo
12 14
  alias OpenAgents.Repositories
15
  alias OpenAgentsWeb.DocsCatalog
13 16
14 17
  @marker_png <<0x89, 0x50, 0x4E, 0x47, "FAKE-CARD-RENDER">>
15 18

@@ -97,6 +100,30 @@ defmodule OpenAgentsWeb.OgImageControllerTest do

97 100
    assert response(get(conn, signed_url(card)), 200) == @marker_png
98 101
  end
99 102
103
  test "pull request cards render from the public pull request path", %{conn: conn} do
104
    {_issue, pull_request} = seed_pull_request("Carded pull request")
105
106
    card =
107
      OpenAgentsWeb.OG.pull_request("OpenAgentsInc", "openagents.com", pull_request)
108
109
    assert response(get(conn, signed_url(card)), 200) == @marker_png
110
  end
111
112
  test "docs cards render from the compile-time catalog and unknown slugs refuse", %{conn: conn} do
113
    {:ok, page} = DocsCatalog.render("stacked-pull-requests")
114
115
    assert response(get(conn, signed_url(OpenAgentsWeb.OG.docs(page))), 200) == @marker_png
116
117
    unknown = %OpenAgentsWeb.OG{
118
      kind: :docs,
119
      heading: "Nope",
120
      page_path: "/docs/never-existed",
121
      path_suffix: ["never-existed"]
122
    }
123
124
    assert response(get(conn, signed_url(unknown)), 404) == ""
125
  end
126
100 127
  test "blob cards pass through the same disclosure gate as the file page", %{
101 128
    conn: conn,
102 129
    repository: _repository

@@ -238,8 +265,44 @@ defmodule OpenAgentsWeb.OgImageControllerTest do

238 265
             ~r|/og/v/[0-9a-f]{12}/repos/OpenAgentsInc/openagents\.com/issues/#{issue.number}\.png\?sig=|
239 266
  end
240 267
268
  test "a pull request page emits a pull-specific card URL", %{conn: conn} do
269
    {issue, _pull_request} = seed_pull_request("Shared pull request")
270
271
    {:ok, _view, html} = live(conn, ~p"/OpenAgentsInc/openagents.com/pulls/#{issue.number}")
272
273
    assert html =~
274
             ~r|/og/v/[0-9a-f]{12}/repos/OpenAgentsInc/openagents\.com/pulls/#{issue.number}\.png\?sig=|
275
  end
276
277
  test "a documentation page emits a docs-specific card URL", %{conn: conn} do
278
    {:ok, _view, html} = live(conn, ~p"/docs/stacked-pull-requests")
279
280
    assert html =~
281
             ~r|property="og:image" content="[^"]*/og/v/[0-9a-f]{12}/docs/stacked-pull-requests\.png\?sig=|
282
  end
283
241 284
  ## Helpers ------------------------------------------------------------------
242 285
286
  defp seed_pull_request(title) do
287
    repository = repository()
288
    {:ok, issue} = Issues.create_issue(repository, %{"title" => title})
289
290
    pull_request =
291
      %PullRequest{}
292
      |> PullRequest.changeset(%{
293
        repository_id: repository.id,
294
        issue_id: issue.id,
295
        head_repository_id: repository.id,
296
        head_ref: "feature",
297
        head_sha: String.duplicate("a", 40),
298
        base_ref: "main",
299
        base_sha: String.duplicate("b", 40)
300
      })
301
      |> Repo.insert!()
302
303
    {issue, Repo.preload(pull_request, [:issue, :head_repository])}
304
  end
305
243 306
  defp committed_asset_path do
244 307
    Application.app_dir(:openagents, "priv/static/images/og-card-default.png")
245 308
  end
test/openagents_web/og_test.exs modified +76

@@ -178,6 +178,82 @@ defmodule OpenAgentsWeb.OGTest do

178 178
    assert hd(wontfix.chips) == %{label: "Closed as not planned", tone: :muted}
179 179
  end
180 180
181
  test "the pull request builder derives state tone, branches, and a stack chip" do
182
    pull_request = %{
183
      issue: %{
184
        number: 119,
185
        title: "Wire stack actions into the pull request page",
186
        user: %{"login" => "chris"},
187
        comments: 2,
188
        inserted_at: ~U[2026-08-22 10:00:00Z]
189
      },
190
      head_ref: "stack-pr-actions",
191
      base_ref: "stack-lifecycle",
192
      state: "open",
193
      draft: false,
194
      merged_at: nil
195
    }
196
197
    card =
198
      OG.pull_request("OpenAgentsInc", "openagents.com", pull_request,
199
        stack_position: 3,
200
        stack_size: 4
201
      )
202
203
    assert card.kind == :pull
204
    assert card.kicker == "OpenAgentsInc/openagents.com · #119"
205
    assert card.description == "stack-pr-actions → stack-lifecycle"
206
    assert card.avatar == "chris"
207
208
    assert [%{label: "Open", tone: :open}, %{label: "Stack layer 3 of 4"}] = card.chips
209
210
    assert "2 comments" in card.stats
211
    assert card.page_path == "/OpenAgentsInc/openagents.com/pulls/119"
212
213
    assert OG.request_path(card) =~
214
             ~r|^/og/v/[0-9a-f]{12}/repos/OpenAgentsInc/openagents\.com/pulls/119\.png$|
215
  end
216
217
  test "merged, closed, and draft pull requests carry their own tones" do
218
    base = %{
219
      issue: %{number: 1, title: "t", user: %{"login" => "a"}, comments: 0, inserted_at: nil},
220
      head_ref: "h",
221
      base_ref: "b",
222
      state: "open",
223
      draft: false,
224
      merged_at: nil
225
    }
226
227
    merged = %{base | state: "closed", merged_at: ~U[2026-08-22 10:00:00Z]}
228
    closed = %{base | state: "closed"}
229
    draft = %{base | draft: true}
230
231
    assert [%{label: "Merged", tone: :done}] = OG.pull_request("o", "r", merged).chips
232
    assert [%{label: "Closed", tone: :muted}] = OG.pull_request("o", "r", closed).chips
233
    assert [%{label: "Draft", tone: :muted}] = OG.pull_request("o", "r", draft).chips
234
235
    # An unstacked pull request shows no stack chip.
236
    assert [%{label: "Open", tone: :open}] = OG.pull_request("o", "r", base).chips
237
  end
238
239
  test "the docs builder flattens the opening paragraph and paths under /docs" do
240
    {:ok, page} = OpenAgentsWeb.DocsCatalog.render("stacked-pull-requests")
241
242
    card = OG.docs(page)
243
244
    assert card.kind == :docs
245
    assert card.kicker =~ "OpenAgents docs"
246
    assert card.heading == "Stacked pull requests"
247
    assert is_binary(card.description) and card.description != ""
248
    refute card.description =~ "]("
249
    refute card.description =~ "`"
250
    assert [%{label: "Documentation"}] = card.chips
251
    assert card.page_path == "/docs/stacked-pull-requests"
252
253
    assert OG.request_path(card) =~
254
             ~r|^/og/v/[0-9a-f]{12}/docs/stacked-pull-requests\.png$|
255
  end
256
181 257
  test "the blob builder infers language and formats size honestly" do
182 258
    card =
183 259
      OG.blob("OpenAgentsInc", "openagents.com", "lib/openagents/og.ex", %{

This page updates live while a promote is in flight · changelog