Public: anyone can read this; the crew can change it.

A wave's own page lists the pulls it carries, each with its title and a link to it #5965

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5942-a-wave-s-own-page-lists-the-pulls-it-car → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/5942-a-wave-s-own-page-lists-the-pulls-it-car was merged into main as 171a098.

Review

Sign in to review this change.
10 files changed +447 −42
src/http/api.ts +13 −8
@@ -387,7 +387,8 @@ import { carriedBy } from "../core/carried.ts";
387387 import { readRunLog, subscribeRunLog } from "../runs/log.ts";
388388 import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts";
389389 import { repoDir } from "../git/repos.ts";
390 import { readWave, readWaves } from "../story/waves.ts";
390 import { carriedPulls, pullPath } from "../story/wave_pulls.ts";
391 import { pullRefs, readWave, readWaves } from "../story/waves.ts";
391392 import type { Result } from "../kernel/result.ts";
392393 import { sizesView } from "../spawn/sizes.ts";
393394 import { holds, weakEtag } from "./etag.ts";
@@ -3697,19 +3698,23 @@ export function apiRoutes(router: Router<Handler>): void {
36973698 );
36983699 if (!entry?.ok) return notFound();
36993700 const w = entry.wave;
3700 // A pull line is `#12 Title` linking to the pull; the number is whichever
3701 // of the two names it, or null.
3702 const number = (p: { title: string; url: string }): number | null => {
3703 const m = /^#(\d+)\b/.exec(p.title) ?? /\/pulls\/(\d+)\/?$/.exec(p.url);
3704 return m ? Number(m[1]) : null;
3705 };
3701 // The page's gate on each pull: one the viewer cannot read is not named.
3702 const pulls = timePhase(ctx.request, "pulls", () =>
3703 carriedPulls(ctx.app.db, pullRefs(w), ctx.auth.user),
3704 );
37063705 return timePhase(ctx.request, "render", () =>
37073706 json({
37083707 version: w.version,
37093708 date: w.date,
37103709 headline: w.headline,
37113710 highlights: w.highlights,
3712 pulls: w.pulls.map((p) => ({ number: number(p), title: p.title, url: p.url })),
3711 pulls: pulls.map((p) => ({
3712 number: p.number,
3713 title: p.title,
3714 url: `${ctx.url.origin}${pullPath(p)}`,
3715 state: p.mergedAt === null ? p.state : "merged",
3716 merged_at: p.mergedAt,
3717 })),
37133718 }),
37143719 );
37153720 });
src/story/wave_pulls.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * The pulls a wave carries, with their live titles: the references come from
3 * the wave file (`waves.ts`), the titles from one query. A pull in a
4 * repository the viewer cannot read, or one that does not exist, is left out.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { canRead, getRepo, type Repo } from "../core/repos.ts";
9 import type { User } from "../core/users.ts";
10 import type { PullRef } from "./waves.ts";
11
12 /** A page, not an archive. */
13 const MAX_PULLS = 200;
14
15 export interface CarriedPull {
16 owner: string;
17 name: string;
18 number: number;
19 title: string;
20 mergedAt: string | null;
21 state: "open" | "closed";
22 }
23
24 interface Row {
25 repo_id: string;
26 number: number;
27 title: string;
28 merged_at: string | null;
29 state: "open" | "closed";
30 }
31
32 export function carriedPulls(
33 db: Database,

Good shape: plain data in and out, one query, placeholders only. This should be the single source every wave door uses (page, tty, API), so nothing renders wave.pulls directly any more.

34 refs: PullRef[],
35 viewer: User | undefined,
36 ): CarriedPull[] {
37 const repos = new Map<string, Repo | null>();
38 const visible: { ref: PullRef; repo: Repo }[] = [];
39 for (const ref of refs.slice(0, MAX_PULLS)) {
40 const key = `${ref.owner}/${ref.name}`;
41 let repo = repos.get(key);
42 if (repo === undefined) {
43 const found = getRepo(db, ref.owner, ref.name);
44 repo = found && canRead(db, found, viewer) ? found : null;
45 repos.set(key, repo);
46 }
47 if (repo) visible.push({ ref, repo });
48 }
49 if (visible.length === 0) return [];
50 const rows = db
51 .query<Row, string[]>(
52 `SELECT issue.repo_id, issue.number, issue.title, issue.state, pull.merged_at
53 FROM issue JOIN pull ON pull.issue_id = issue.id
54 WHERE issue.kind = 'pull' AND (${visible
55 .map(() => "(issue.repo_id = ? AND issue.number = ?)")
56 .join(" OR ")})`,
57 )
58 .all(...visible.flatMap(({ ref, repo }) => [repo.id, String(ref.number)]));
59 const byKey = new Map(rows.map((r) => [`${r.repo_id}#${r.number}`, r]));
60 return visible.flatMap(({ ref, repo }) => {
61 const r = byKey.get(`${repo.id}#${ref.number}`);
62 return r
63 ? [{ owner: ref.owner, name: ref.name, number: ref.number, title: r.title, mergedAt: r.merged_at, state: r.state }]
64 : [];
65 });
66 }
67
68 /** Where the pull lives on this forge: the local path, never the host the wave file was written with. */
69 export const pullPath = (p: Pick<CarriedPull, "owner" | "name" | "number">): string =>
70 `/${encodeURIComponent(p.owner)}/${encodeURIComponent(p.name)}/pulls/${p.number}`;
src/story/waves.ts +39
@@ -124,6 +124,45 @@ export async function readWaves(
124124 return [...good, ...bad];
125125 }
126126
127 export interface PullRef {
128 owner: string;
129 name: string;
130 number: number;
131 }
132
133 const PULL_PATH = /^\/([^/]+)\/([^/]+)\/pulls\/([1-9]\d{0,8})\/?$/;
134
135 /**
136 * The pulls a wave names, as `owner/name#number`, in the file's order and
137 * each once. A link that is not a pull's is not a reference and is left out.
138 */
139 export function pullRefs(wave: Wave): PullRef[] {
140 const seen = new Set<string>();
141 const out: PullRef[] = [];
142 for (const p of wave.pulls) {
143 let path: string;
144 try {
145 path = new URL(p.url).pathname;
146 } catch {
147 continue;
148 }
149 const m = PULL_PATH.exec(path);
150 if (!m) continue;
151 let owner: string;
152 let name: string;
153 try {
154 owner = decodeURIComponent(m[1]!);
155 name = decodeURIComponent(m[2]!);
156 } catch {
157 continue;
158 }
159 const key = `${owner}/${name}#${m[3]}`;
160 if (seen.has(key)) continue;
161 seen.add(key);
162 out.push({ owner, name, number: Number(m[3]) });
163 }
164 return out;
165 }
127166
128167 /** Unreadable files tried before giving up on finding the newest good wave. */
129168 const LATEST_TRIES = 5;
src/tui/screens/waves.ts +16 −7
@@ -7,7 +7,8 @@
77
88 import { canRead } from "../../core/repos.ts";
99 import { VERSION } from "../../story/wave.ts";
10 import { readWave, readWaves, type WaveEntry } from "../../story/waves.ts";
10 import { carriedPulls, pullPath, type CarriedPull } from "../../story/wave_pulls.ts";
11 import { pullRefs, readWave, readWaves, type WaveEntry } from "../../story/waves.ts";
1112 import { NO_WAVES } from "../../story/words.ts";
1213 import type { RepoSource } from "../../view/source.ts";
1314 import type { Ctx, Screen } from "../app/app.ts";
@@ -38,8 +39,8 @@ const plain = (s: string): string =>
3839 // eslint-disable-next-line no-control-regex
3940 s.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]+/g, " ");
4041
41 /** One wave as lines of plain text: headline, version and date, highlights with why, pulls as title and URL. */
42 export function waveLines(entry: WaveEntry): string[] {
42 /** One wave as lines of plain text: headline, version and date, highlights with why, then the pulls the viewer may see. */
43 export function waveLines(entry: WaveEntry, pulls: CarriedPull[]): string[] {
4344 if (!entry.ok) return [plain(entry.file), "", `Not a wave: ${plain(entry.reason)}`];
4445 const w = entry.wave;
4546 return [
@@ -50,12 +51,17 @@ export function waveLines(entry: WaveEntry): string[] {
5051 ...w.highlights.map((h) => ` ${plain(h.title)} - ${plain(h.why)}`),
5152 "",
5253 "Pulls",
53 ...w.pulls.flatMap((p) => [` ${plain(p.title)}`, ` ${plain(p.url)}`]),
54 ...(pulls.length === 0
55 ? [" No pulls to show here."]
56 : pulls.flatMap((p) => [
57 ` #${p.number} ${plain(p.title)} ${p.mergedAt === null ? p.state : `merged ${p.mergedAt.slice(0, 10)}`}`,
58 ` ${plain(pullPath(p))}`,
59 ])),
5460 ];
5561 }
5662
57 function waveScreen(ctx: Ctx, entry: WaveEntry): Screen {
58 const lines = waveLines(entry);
63 function waveScreen(ctx: Ctx, entry: WaveEntry, pulls: CarriedPull[]): Screen {
64 const lines = waveLines(entry, pulls);
5965 const scroll = new Scroll();
6066 return {
6167 title: entry.ok ? entry.wave.version : entry.file,
@@ -90,7 +96,10 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
9096 const one = VERSION.test(version)
9197 ? await readWave(f.dir(repo), repo.defaultBranch, version)
9298 : entry;
93 if (one) ctx.push(waveScreen(ctx, one));
99 if (!one) return;
100 // The page's gate on each pull: one the viewer cannot read is not named.
101 const pulls = one.ok ? carriedPulls(f.db, pullRefs(one.wave), f.user) : [];
102 ctx.push(waveScreen(ctx, one, pulls));
94103 });
95104 };
96105
src/web/pages/waves.ts +20 −12
@@ -13,7 +13,8 @@ import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../cor
1313 import { repoDir } from "../../git/repos.ts";
1414 import type { Ctx } from "../../http/context.ts";
1515 import { VERSION } from "../../story/wave.ts";
16 import { readWave, readWaves } from "../../story/waves.ts";
16 import { carriedPulls, pullPath } from "../../story/wave_pulls.ts";
17 import { pullRefs, readWave, readWaves } from "../../story/waves.ts";
1718 import { NO_WAVES } from "../../story/words.ts";
1819 import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts";
1920 import { escapeHtml, html } from "../html.ts";
@@ -135,9 +136,6 @@ ${entries.join("\n")}
135136 });
136137 }
137138
138 /** Only http(s) becomes a link; anything else is shown as plain text. */
139 export const webUrl = (s: string): boolean => /^https?:\/\//i.test(s);
140
141139 // GET /:owner/:name/waves/:version
142140 export async function wavePage(ctx: Ctx): Promise<Response> {
143141 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -151,6 +149,11 @@ export async function wavePage(ctx: Ctx): Promise<Response> {
151149 ),
152150 );
153151 if (!entry) return notFoundPage(ctx);
152 const carried = entry.ok
153 ? timePhase(ctx.request, "pulls", () =>
154 carriedPulls(ctx.app.db, pullRefs(entry.wave), ctx.auth.user),
155 )
156 : [];
154157 const rendering = performance.now();
155158 const scope = localScope(ctx, repo);
156159 const back = html`<a href="${repoBase(repo)}/waves">All waves</a>`;
@@ -164,14 +167,19 @@ export async function wavePage(ctx: Ctx): Promise<Response> {
164167 (h) => html`<li><strong>${h.title}</strong> — ${h.why}</li>`,
165168 )}
166169 </ul>
167 <h2>Pulls</h2>
168 <ul>
169 ${entry.wave.pulls.map((p) =>
170 webUrl(p.url)
171 ? html`<li><a href="${p.url}" rel="noopener noreferrer">${p.title}</a></li>`
172 : html`<li>${p.title}</li>`,
173 )}
174 </ul>`
170 ${carried.length === 0

This gates the page only. src/tui/screens/waves.ts:53 and src/http/api.ts:3696 still render entry.wave.pulls straight from the file, so a private pull's title reaches an outsider through ssh and the API. Use carriedPulls in all three doors.

171 ? html`<p class="muted">No pulls to show here.</p>`

Nice-to-have: when this branch is taken, the Pulls heading disappears and the note sits under the highlights with no label. Render <h2>Pulls</h2> above it so the section stays in place.

172 : listCard(
173 carried.map((p) =>
174 row({
175 href: pullPath(p),
176 title: html`#${p.number} ${p.title}`,
177 meta: html`${p.mergedAt === null ? p.state : `merged ${p.mergedAt.slice(0, 10)}`}`,
178 state: p.mergedAt === null ? p.state : ("merged" as const),
179 }),
180 ),
181 { head: html`<h2>Pulls</h2>` },
182 )}`
175183 : html`<h1>${entry.file}</h1>
176184 <p class="muted">Not a wave: ${entry.reason}</p>`;
177185
test/api-waves.test.ts +58 −6
@@ -5,11 +5,13 @@ import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import type { Repo } from "../src/core/repos.ts";
89 import { createSession } from "../src/core/sessions.ts";
910 import { createUser } from "../src/core/users.ts";
1011 import { serve, type Servers } from "../src/http/server.ts";
1112 import { writeWave, type Wave } from "../src/story/wave.ts";
1213 import { quietLogs } from "./helpers/globals.ts";
14 import { mergedPull, waveNaming } from "./helpers/wave-pulls.ts";
1315
1416 quietLogs();
1517
@@ -17,6 +19,9 @@ let dataDir: string;
1719 let app: App;
1820 let servers: Servers;
1921 let cookie = "";
22 let bobCookie = "";
23 let shown = 0;
24 let secret = 0;
2025 const cells = process.env["FORGE_CELLS"];
2126
2227 const wave = (version: string, date: string, headline: string): Wave => ({
@@ -52,12 +57,47 @@ beforeAll(async () => {
5257 if (!made.ok) throw new Error(String(made.error));
5358 const alice = made.value;
5459 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
60 const bob = await createUser(app.db, {
61 origin: "seed",
62 trusted: true,
63 username: "bob",
64 email: "[email protected]",
65 password: "a-good-passphrase",
66 });
67 if (!bob.ok) throw new Error(String(bob.error));
68 bobCookie = `forge_session=${createSession(app.db, bob.value.id).token}`;
5569 for (const [name, visibility] of [["pub", "public"], ["priv", "private"]] as const) {
5670 const repo = await createRepo(app, alice, { name, visibility });
5771 if (!repo.ok) throw new Error(String(repo.error));
5872 const c = await commitToBranch(app, alice, repo.value, { message: "waves", files });
5973 if (!c.ok) throw new Error(JSON.stringify(c.error));
6074 }
75 const repos = new Map<string, Repo>();
76 for (const [name, visibility] of [["mix", "public"], ["hid", "private"]] as const) {
77 const r = await createRepo(app, alice, { name, visibility });
78 if (!r.ok) throw new Error(String(r.error));
79 const c = await commitToBranch(app, alice, r.value, {
80 message: "init",
81 files: [{ path: "README.md", content: "hi\n" }],
82 });
83 if (!c.ok) throw new Error(JSON.stringify(c.error));
84 repos.set(name, r.value);
85 }
86 shown = await mergedPull(app, alice, repos.get("mix")!, "Shown work");
87 secret = await mergedPull(app, alice, repos.get("hid")!, "Secret work");
88 const c = await commitToBranch(app, alice, repos.get("mix")!, {
89 message: "wave",
90 files: [
91 {
92 path: "waves/0.1.0.md",
93 content: waveNaming("0.1.0", [
94 { owner: "alice", name: "mix", number: shown },
95 { owner: "alice", name: "hid", number: secret },
96 ]),
97 },
98 ],
99 });
100 if (!c.ok) throw new Error(JSON.stringify(c.error));
61101 });
62102
63103 afterAll(async () => {
@@ -68,8 +108,10 @@ afterAll(async () => {
68108 await rm(dataDir, { recursive: true, force: true });
69109 });
70110
71 async function get(path: string, withCookie = true) {
72 const res = await fetch(`${servers.url}${path}`, { headers: withCookie ? { cookie } : {} });
111 async function get(path: string, withCookie: boolean | string = true) {
112 const res = await fetch(`${servers.url}${path}`, {
113 headers: withCookie === false ? {} : { cookie: withCookie === true ? cookie : withCookie },
114 });
73115 const body = await res.text();
74116 const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!);
75117 return { status: res.status, names: names.slice(names.indexOf("app") + 1), body };
@@ -93,9 +135,19 @@ test("one wave carries highlights with their why, and its pull numbers", async (
93135 const w = JSON.parse(got.body);
94136 expect(w.headline).toBe("Ninth");
95137 expect(w.highlights[0]).toEqual({ title: "Thing 1", why: "because 1" });
96 expect(w.pulls).toEqual([
97 { number: 12, title: "#12 A pull", url: "https://forge.example/o/r/pulls/12" },
98 ]);
138 expect(w.pulls).toEqual([]);
139 });
140
141 test("a wave's pulls are the page's: the owner sees the private one, an outsider does not", async () => {
142 const owner = JSON.parse((await get("/api/v1/repos/alice/mix/waves/0.1.0")).body);
143 expect(owner.pulls.map((p: { title: string }) => p.title)).toEqual(["Shown work", "Secret work"]);
144 expect(owner.pulls[0]).toMatchObject({ number: shown, state: "merged", merged_at: "2026-03-04T10:00:00.000Z" });
145 expect(owner.pulls[0].url).toBe(`${servers.url}/alice/mix/pulls/${shown}`);
146 const outsider = await get("/api/v1/repos/alice/mix/waves/0.1.0", bobCookie);
147 expect(outsider.status).toBe(200);
148 expect(outsider.body).toContain("Shown work");
149 expect(outsider.body).not.toContain("Secret work");
150 expect(outsider.body).not.toContain("/alice/hid/pulls/");
99151 });
100152
101153 test("an unknown or malformed version is 404", async () => {
@@ -112,5 +164,5 @@ test("a stranger gets 404 for a private repository, an owner 200", async () => {
112164
113165 test("the reads name their Server-Timing phases", async () => {
114166 expect((await get("/api/v1/repos/alice/pub/waves")).names).toEqual(["session", "waves", "render"]);
115 expect((await get("/api/v1/repos/alice/pub/waves/0.9.0")).names).toEqual(["session", "wave", "render"]);
167 expect((await get("/api/v1/repos/alice/pub/waves/0.9.0")).names).toEqual(["session", "wave", "pulls", "render"]);
116168 });
test/helpers/wave-pulls.ts added +47
@@ -0,0 +1,47 @@
1 /** A merged pull on a real branch, and a wave file naming pulls by local link — for the doors to a wave. */
2 import { openPull } from "../../src/actions/pulls.ts";
3 import { commitToBranch } from "../../src/actions/repos.ts";
4 import type { App } from "../../src/app.ts";
5 import type { Repo } from "../../src/core/repos.ts";
6 import type { User } from "../../src/core/users.ts";
7 import { writeWave } from "../../src/story/wave.ts";
8
9 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
10 if (!r.ok) throw new Error(JSON.stringify(r.error));
11 return r.value;
12 };
13
14 /** Opens a pull titled `title` from a new branch, marks it merged, returns its number. */
15 export async function mergedPull(app: App, owner: User, repo: Repo, title: string): Promise<number> {
16 const branch = `t-${title.replace(/\W/g, "")}`;
17 must(
18 await commitToBranch(app, owner, repo, {
19 branch,
20 message: title,
21 files: [{ path: `${branch}.txt`, content: "x" }],
22 }),
23 );
24 const p = must(
25 await openPull(app, owner, repo, { title, body: "", baseBranch: "main", headBranch: branch }),
26 );
27 app.db
28 .query("UPDATE pull SET merged_at = '2026-03-04T10:00:00.000Z' WHERE issue_id = ?")
29 .run(p.id);
30 return p.number;
31 }
32
33 /** The text of a wave file whose pulls are the given `owner/name#number` links. */
34 export function waveNaming(version: string, refs: { owner: string; name: string; number: number }[]): string {
35 const w = writeWave({
36 version,
37 date: "2026-03-05",
38 headline: "Mixed",
39 highlights: [1, 2, 3].map((n) => ({ title: `Thing ${n}`, why: "because" })),
40 pulls: refs.map((r) => ({
41 title: `#${r.number} as written`,
42 url: `https://forge.example/${r.owner}/${r.name}/pulls/${r.number}`,
43 })),
44 });
45 if (!w.ok) throw w.error;
46 return w.value;
47 }
test/tui-waves.test.ts +38 −5
@@ -14,6 +14,7 @@ import type { RepoSource } from "../src/view/source.ts";
1414 import { local } from "../src/tui/screens/where.ts";
1515 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1616 import { quietLogs } from "./helpers/globals.ts";
17 import { mergedPull, waveNaming } from "./helpers/wave-pulls.ts";
1718 import { settleApp } from "./helpers/tui.ts";
1819 import { fixtureSlot } from "./helpers/slot.ts";
1920 import { seedAdmin } from "./helpers/admin.ts";
@@ -63,6 +64,7 @@ describe("waves in the terminal", () => {
6364 let bob: User;
6465 let pub: Repo;
6566 let priv: Repo;
67 let mix: Repo;
6668
6769 beforeAll(async () => {
6870 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-waves-"));
@@ -75,8 +77,11 @@ describe("waves in the terminal", () => {
7577 const p = await createRepo(forge, alice, { name: "pub", visibility: "public" });
7678 const q = await createRepo(forge, alice, { name: "priv", visibility: "private" });
7779 if (!p.ok || !q.ok) throw new Error("repo failed");
80 const m = await createRepo(forge, alice, { name: "mix", visibility: "public" });
81 if (!m.ok) throw new Error("repo failed");
7882 pub = p.value;
7983 priv = q.value;
84 mix = m.value;
8085 const put = (repo: Repo, files: { path: string; content: string }[]) =>
8186 commitToBranch(forge, alice, repo, { message: "waves", files });
8287 await put(pub, [
@@ -84,6 +89,19 @@ describe("waves in the terminal", () => {
8489 { path: "waves/0.10.0.md", content: text(wave("0.10.0", "2026-02-01", "Tenth")) },
8590 { path: "waves/broken.md", content: "no front matter\n" },
8691 ]);
92 for (const r of [mix, priv])
93 await commitToBranch(forge, alice, r, { message: "init", files: [{ path: "README.md", content: "hi\n" }] });
94 const shown = await mergedPull(forge, alice, mix, "Shown work");
95 const secret = await mergedPull(forge, alice, priv, "Secret work");
96 await put(mix, [
97 {
98 path: "waves/0.1.0.md",
99 content: waveNaming("0.1.0", [
100 { owner: "alice", name: "mix", number: shown },
101 { owner: "alice", name: "priv", number: secret },
102 ]),
103 },
104 ]);
87105 await put(priv, [
88106 { path: "waves/1.0.0.md", content: text(wave("1.0.0", "2026-03-01", "Secret launch")) },
89107 ]);
@@ -142,8 +160,20 @@ describe("waves in the terminal", () => {
142160 expect(h.text).toContain("Tenth");
143161 expect(h.text).toContain("0.10.0 2026-02-01");
144162 expect(h.text).toContain("Thing 1 - because");
145 expect(h.text).toContain("#1 A pull");
146 expect(h.text).toContain("https://forge.example/o/r/pulls/1");
163 expect(h.text).toContain("No pulls to show here.");
164 });
165
166 test("a wave's pulls are the page's: the owner sees the private one, an outsider does not", async () => {
167 const owner = await open(alice, mix, ["enter"]);
168 owner.stop();
169 expect(owner.text).toContain("Shown work merged 2026-03-04");
170 expect(owner.text).toContain("/alice/mix/pulls/");
171 expect(owner.text).toContain("Secret work");
172 const outsider = await open(bob, mix, ["enter"]);
173 outsider.stop();
174 expect(outsider.text).toContain("Shown work");
175 expect(outsider.text).not.toContain("Secret work");
176 expect(outsider.text).not.toContain("/alice/priv/pulls/");
147177 });
148178
149179 test("enter on an unparsable row shows its reason", async () => {
@@ -154,7 +184,8 @@ describe("waves in the terminal", () => {
154184 });
155185
156186 test("wave text carries no escape or control characters", () => {
157 const lines = waveLines({
187 const lines = waveLines(
188 {
158189 ok: true,
159190 file: "1.0.0.md",
160191 wave: {
@@ -162,9 +193,11 @@ describe("waves in the terminal", () => {
162193 date: "2026-01-01",
163194 headline: "Hi\u001b[31m red\u0007\u202e",
164195 highlights: [{ title: "T\u001b]0;x\u0007", why: "w\nz" }],
165 pulls: [{ title: "P", url: "https://x.example/\u001b[2J" }],
196 pulls: [],
166197 },
167 });
198 },
199 [{ owner: "o", name: "r", number: 1, title: "P\u001b[2J", mergedAt: null, state: "open" }],
200 );
168201 expect(lines.join("\n")).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/);
169202 });
170203
test/wave-pulls.test.ts added +146
@@ -0,0 +1,146 @@
1 /** A wave's page lists the pulls it names: title, date, link; the unseen are left out (#5942). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import type { Repo } from "../src/core/repos.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { pullRefs } from "../src/story/waves.ts";
13 import { writeWave } from "../src/story/wave.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15 import { mergedPull } from "./helpers/wave-pulls.ts";
16
17 quietLogs();
18
19 let root = "";
20 let app: App;
21 let servers: Servers;
22 let alice: User;
23 let bob: User;
24 let cookie = "";
25 let bobCookie = "";
26
27 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
28 if (!r.ok) throw new Error(JSON.stringify(r.error));
29 return r.value;
30 };
31
32 const pull = (owner: User, repo: Repo, title: string) => mergedPull(app, owner, repo, title);
33
34 beforeAll(async () => {
35 root = mkdtempSync(join(tmpdir(), "forge-wave-pulls-"));
36 app = createApp({ dataDir: root, port: 0 });
37 servers = await serve(app);
38 const mk = async (username: string, adminIfFirst: boolean) =>
39 must(
40 await createUser(app.db, {
41 origin: "seed",
42 trusted: true,
43 username,
44 email: `${username}@forge.local`,
45 password: "a-good-passphrase",
46 adminIfFirst,
47 }),
48 );
49 alice = await mk("alice", true);
50 bob = await mk("bob", false);
51 cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=dev`;
52 bobCookie = `forge_session=${createSession(app.db, bob.id).token}; forge_mode=dev`;
53 const pub = must(await createRepo(app, alice, { name: "pub", visibility: "public" }));
54 const priv = must(await createRepo(app, alice, { name: "priv", visibility: "private" }));
55 for (const [u, r] of [[alice, pub], [alice, priv]] as const)
56 must(await commitToBranch(app, u, r, { message: "init", files: [{ path: "README.md", content: "hi\n" }] }));
57 const a = await pull(alice, pub, "Add the <b>thing</b>");
58 const b = await pull(alice, pub, "Fix the other");
59 const c = await pull(alice, priv, "Secret work");
60 const d = await pull(alice, pub, "Dropped idea");
61 app.db
62 .query("UPDATE pull SET merged_at = NULL WHERE issue_id IN (SELECT id FROM issue WHERE repo_id = ? AND number = ?)")
63 .run(pub.id, d);
64 app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ? AND number = ?").run(pub.id, d);
65 const base = "https://forge.example";
66 const w = writeWave({
67 version: "0.1.0",
68 date: "2026-03-05",
69 headline: "First",
70 highlights: [1, 2, 3].map((n) => ({ title: `Thing ${n}`, why: "because" })),
71 pulls: [
72 { title: `#${a} x`, url: `${base}/alice/pub/pulls/${a}` },
73 { title: `#${c} Secret work`, url: `${base}/alice/priv/pulls/${c}` },
74 { title: `#${b} y`, url: `${base}/alice/pub/pulls/${b}` },
75 { title: `#${d} z`, url: `${base}/alice/pub/pulls/${d}` },
76 { title: "#999 gone", url: `${base}/alice/pub/pulls/999` },
77 ],
78 });
79 if (!w.ok) throw w.error;
80 must(await commitToBranch(app, alice, pub, { message: "wave", files: [{ path: "waves/0.1.0.md", content: w.value }] }));
81 const e = writeWave({
82 version: "0.2.0",
83 date: "2026-03-06",
84 headline: "Hidden",
85 highlights: [1, 2, 3].map((n) => ({ title: `Thing ${n}`, why: "because" })),
86 pulls: [{ title: `#${c} Secret work`, url: `${base}/alice/priv/pulls/${c}` }],
87 });
88 if (!e.ok) throw e.error;
89 must(await commitToBranch(app, alice, pub, { message: "wave 2", files: [{ path: "waves/0.2.0.md", content: e.value }] }));
90 }, 60_000);
91 afterAll(() => {
92 servers.stop();
93 app.close();
94 rmSync(root, { recursive: true, force: true });
95 });
96
97 test("rows give #N title · merged date, link to the pull, and leave out the unseen", async () => {
98 const res = await fetch(`${servers.url}/alice/pub/waves/0.1.0`, { headers: { cookie: bobCookie } });
99 const body = await res.text();
100 expect(res.status).toBe(200);
101 expect(body).toContain("Add the &lt;b&gt;thing&lt;/b&gt;");
102 expect(body).not.toContain("<b>thing");
103 expect(body).toContain("Fix the other");
104 expect(body).toContain("merged 2026-03-04");
105 expect(body).toMatch(/href="\/alice\/pub\/pulls\/\d+"[^>]*class="row"|class="row"[^>]*href="\/alice\/pub\/pulls\/\d+"/);
106 expect(body).not.toContain("Secret work");
107 expect(body).not.toContain("/alice/priv/pulls/");
108 expect(body).not.toContain("#999");
109 });
110
111 test("a closed pull that never merged shows its state", async () => {
112 const body = await (await fetch(`${servers.url}/alice/pub/waves/0.1.0`, { headers: { cookie: bobCookie } })).text();
113 expect(body).toContain("Dropped idea");
114 expect(body).not.toContain("not merged");
115 expect(body).toMatch(/data-state="closed"/);
116 });
117
118 test("a wave whose pulls are all hidden says so", async () => {
119 const body = await (await fetch(`${servers.url}/alice/pub/waves/0.2.0`, { headers: { cookie: bobCookie } })).text();
120 expect(body).toContain("No pulls to show here.");
121 expect(body).not.toContain("Secret work");
122 });
123
124 test("the owner of the private repository sees its pull", async () => {
125 const body = await (await fetch(`${servers.url}/alice/pub/waves/0.1.0`, { headers: { cookie } })).text();
126 expect(body).toContain("Secret work");
127 });
128
129 test("refs are read from the file and the phase is named", async () => {
130 const refs = pullRefs({
131 version: "0.1.0",
132 date: "2026-03-05",
133 headline: "h",
134 highlights: [],
135 pulls: [
136 { title: "a", url: "https://x.example/alice/pub/pulls/1" },
137 { title: "a again", url: "https://x.example/alice/pub/pulls/1/" },
138 { title: "not a pull", url: "https://x.example/alice/pub/issues/1" },
139 { title: "bad", url: "nonsense" },
outdated

Wall-clock assertion; will flake under load. Remove it.

140 ],
141 });
142 expect(refs).toEqual([{ owner: "alice", name: "pub", number: 1 }]);
143 const res = await fetch(`${servers.url}/alice/pub/waves/0.1.0`, { headers: { cookie } });
144 await res.text();
145 expect(res.headers.get("server-timing") ?? "").toMatch(/pulls;dur=\d+\.\d/);
146 });
test/waves-page.test.ts −4
@@ -8,7 +8,6 @@ import type { Repo } from "../src/core/repos.ts";
88 import { createSession } from "../src/core/sessions.ts";
99 import { createUser, type User } from "../src/core/users.ts";
1010 import { serve, type Servers } from "../src/http/server.ts";
11 import { webUrl } from "../src/web/pages/waves.ts";
1211 import { writeWave, type Wave } from "../src/story/wave.ts";
1312
1413 const PASSWORD = "a-good-passphrase";
@@ -156,7 +155,6 @@ describe("waves page", () => {
156155 expect(body).toContain("2026-02-01");
157156 expect(body).toContain("Thing 1");
158157 expect(body).toContain("because");
159 expect(body).toContain('href="https://forge.example/o/r/pulls/1"');
160158 });
161159
162160 test("values are escaped and a javascript: pull is not a link", async () => {
@@ -166,8 +164,6 @@ describe("waves page", () => {
166164 expect(body).not.toContain("<b>links");
167165 const js = await (await get("/alice/pub/waves/0.5.0")).text();
168166 expect(js).not.toContain('href="javascript:');
169 expect(webUrl("javascript:alert(1)")).toBe(false);
170 expect(webUrl("https://x.example/p/1")).toBe(true);
171167 });
172168
173169 test("an unknown or malformed version is a 404; an unparsable file shows its reason", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs