Public: anyone can read this; the crew can change it.
An issue, change or comment's origin link draws §5's link and muted text, and .origin-link leaves the markup #4334
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4293-an-issue-change-or-comment-s-origin-link → main 3 commits fork 9 threads · 9 unresolved
6 files
changed +115
−4
src/web/comments.ts +1 −1
| @@ -46,7 +46,7 @@ export function commentCard(card: CommentCard): Html { | ||
| 46 | 46 | card.originUrl != null && card.originUrl !== "", |
| 47 | 47 | () => html`<span class="spacer"></span> |
| 48 | 48 | <a |
| 49 | class="origin-link small" | |
| 49 | class="bar muted small" | |
| 50 | 50 | href="${card.originUrl ?? ""}" |
| 51 | 51 | rel="noreferrer noopener" |
| 52 | 52 | >${icon("external")}view on origin</a |
src/web/pages/browse.ts +1 −1
| @@ -722,7 +722,7 @@ async function browseThread( | ||
| 722 | 722 | ${when( |
| 723 | 723 | detail.issue.originUrl, |
| 724 | 724 | (url) => |
| 725 | html`<a class="origin-link" href="${url}" rel="noreferrer noopener" | |
| 725 | html`<a class="bar muted small" href="${url}" rel="noreferrer noopener" | |
| 726 | 726 | >${icon("external")}view on origin</a |
| 727 | 727 | >`, |
| 728 | 728 | )} |
src/web/studio/change.ts +1 −1
| @@ -214,7 +214,7 @@ export function changeHead(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 214 | 214 | ${when( |
| 215 | 215 | pull.originUrl, |
| 216 | 216 | (url) => |
| 217 | html`<a class="origin-link" href="${url}" rel="noreferrer noopener" | |
| 217 | html`<a class="bar muted small" href="${url}" rel="noreferrer noopener" | |
| 218 | 218 | >${icon("external")}view on origin</a |
| 219 | 219 | >`, |
| 220 | 220 | )} |
src/web/studio/tell.ts +1 −1
| @@ -354,7 +354,7 @@ async function studioTell( | ||
| 354 | 354 | ${when( |
| 355 | 355 | issue.originUrl, |
| 356 | 356 | (url) => |
| 357 | html`<a class="origin-link" href="${url}" rel="noreferrer noopener" | |
| 357 | html`<a class="bar muted small" href="${url}" rel="noreferrer noopener" | |
| 358 | 358 | >${icon("external")}view on origin</a |
| 359 | 359 | >`, |
| 360 | 360 | )} |
test/web-origin-link.test.ts added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | /** | |
| 2 | * "view on origin" on a change's head and on a comment card is drawn with | |
| 3 | * §5's muted small text and the bar's icon gap, never the retired | |
| 4 | * `.origin-link` (#4293). Each variant has its own assertion because each is | |
| 5 | * rendered by its own module. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { openPull } from "../src/actions/pulls.ts"; | |
| 13 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { commentCard } from "../src/web/comments.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | const URL_AT_ORIGIN = "https://origin.example/octo/widgets/pulls/3?a=1&b=2"; | |
| 22 | const anchor = | |
| 23 | /<a\s+class="bar muted small"\s+href="https:\/\/origin\.example\/octo\/widgets\/pulls\/3\?a=1&b=2"\s+rel="noreferrer noopener"/; | |
| 24 | ||
| 25 | let dataDir: string; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let page = ""; | |
| 29 | ||
| 30 | const ok = <T>( | |
| 31 | r: { ok: true; value: T } | { ok: false; error: unknown }, | |
| 32 | ): T => { | |
| 33 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 34 | return r.value; | |
| 35 | }; | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dataDir = await mkdtemp(join(tmpdir(), "forge-origin-link-")); | |
| 39 | app = createApp({ dataDir, port: 0 }); | |
| 40 | servers = await serve(app); | |
| 41 | const alice = ok( | |
| 42 | await createUser(app.db, { | |
| 43 | origin: "seed", | |
| 44 | trusted: true, | |
| 45 | username: "alice", | |
| 46 | email: "[email protected]", | |
| 47 | password: "a-good-passphrase", | |
| 48 | adminIfFirst: true, | |
| 49 | }), | |
| 50 | ); | |
| 51 | const repo = ok(await createRepo(app, alice, { name: "sq", visibility: "public" })); | |
| 52 | ok( | |
| 53 | await commitToBranch(app, alice, repo, { | |
| 54 | message: "Start", | |
| 55 | files: [{ path: "README.md", content: "# sq\n" }], | |
| 56 | }), | |
| 57 | ); | |
| 58 | ok( | |
| 59 | await commitToBranch(app, alice, repo, { | |
| 60 | branch: "topic", | |
| 61 | base: "main", | |
| 62 | message: "Topic", | |
| 63 | files: [{ path: "README.md", content: "# sq\nmore\n" }], | |
| 64 | }), | |
| 65 | ); | |
| 66 | const pull = ok( | |
| 67 | await openPull(app, alice, repo, { | |
| 68 | title: "Topic", | |
| 69 | body: "", | |
| 70 | baseBranch: "main", | |
| 71 | headBranch: "topic", | |
| 72 | }), | |
| 73 | ); | |
| 74 | app.db | |
| 75 | .query("UPDATE issue SET origin_html_url = ? WHERE id = ?") | |
| 76 | .run(URL_AT_ORIGIN, pull.id); | |
| 77 | page = await ( | |
| 78 | await fetch(`${servers.url}/alice/sq/pulls/${pull.number}`) | |
| 79 | ).text(); | |
| 80 | }); | |
| 81 | ||
| 82 | afterAll(async () => { | |
| 83 | servers.stop(); | |
| 84 | app.close(); | |
| 85 | await rm(dataDir, { recursive: true, force: true }); | |
| 86 | }); | |
| 87 | ||
| 88 | test("an imported change's head links to the origin in §5's muted text", () => { | |
| 89 | expect(page).toContain("view on origin"); | |
| 90 | expect(page).not.toContain("origin-link"); | |
| 91 | expect(page).toMatch(anchor); | |
| 92 | }); | |
| 93 | ||
| 94 | test("an imported comment card links to the origin in §5's muted text", () => { | |
| 95 | const card = commentCard({ | |
| 96 | id: "c1", | |
| 97 | author: "alice", | |
| 98 | at: "2026-01-01T00:00:00.000Z", | |
| 99 | body: "hello", | |
| 100 | verb: "commented", | |
| 101 | originUrl: URL_AT_ORIGIN, | |
| 102 | }).toString(); | |
| 103 | expect(card).toContain("view on origin"); | |
| 104 | expect(card).not.toContain("origin-link"); | |
| 105 | expect(card).toMatch(anchor); | |
| 106 | }); |
test/web-origins.test.ts +5
| @@ -378,6 +378,11 @@ describe("origins on the web", () => { | ||
| 378 | 378 | const page = await (await asUser(alice, "/alice/widgets/issues/7")).text(); |
| 379 | 379 | expect(page).toContain("mona"); |
| 380 | 380 | expect(page).toContain("view on origin"); |
| 381 | // §5's muted small text, not the retired .origin-link. | |
| 382 | expect(page).not.toContain("origin-link"); | |
| 383 | expect(page).toMatch( | |
| 384 | /<a class="bar muted small" href="[^"]+" rel="noreferrer noopener"/, | |
outdated
Only the issue page is asserted. Add the same not.toContain('origin-link') and anchor-shape check for an imported change and an imported comment. |
||
| 385 | ); | |
| 381 | 386 | }); |
| 382 | 387 | |
| 383 | 388 | test("commenting on a mirrored issue goes through to the origin", async () => { |
new above
offline
muted small drops the old inline-flex and 4px gap, so the icon sits flush against the text. Restore the spacing with an existing utility.