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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5942-a-wave-s-own-page-lists-the-pulls-it-car → main 4 commits fork 4 threads · 4 unresolved
src/http/api.ts +13 −8
| @@ -387,7 +387,8 @@ import { carriedBy } from "../core/carried.ts"; | ||
| 387 | 387 | import { readRunLog, subscribeRunLog } from "../runs/log.ts"; |
| 388 | 388 | import { blobForRender, commitLog, listRefs, listTree } from "../git/read.ts"; |
| 389 | 389 | 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"; | |
| 391 | 392 | import type { Result } from "../kernel/result.ts"; |
| 392 | 393 | import { sizesView } from "../spawn/sizes.ts"; |
| 393 | 394 | import { holds, weakEtag } from "./etag.ts"; |
| @@ -3697,19 +3698,23 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3697 | 3698 | ); |
| 3698 | 3699 | if (!entry?.ok) return notFound(); |
| 3699 | 3700 | 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 | ); | |
| 3706 | 3705 | return timePhase(ctx.request, "render", () => |
| 3707 | 3706 | json({ |
| 3708 | 3707 | version: w.version, |
| 3709 | 3708 | date: w.date, |
| 3710 | 3709 | headline: w.headline, |
| 3711 | 3710 | 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 | })), | |
| 3713 | 3718 | }), |
| 3714 | 3719 | ); |
| 3715 | 3720 | }); |
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, | |
| 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( | ||
| 124 | 124 | return [...good, ...bad]; |
| 125 | 125 | } |
| 126 | 126 | |
| 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 | } | |
| 127 | 166 | |
| 128 | 167 | /** Unreadable files tried before giving up on finding the newest good wave. */ |
| 129 | 168 | const LATEST_TRIES = 5; |
src/tui/screens/waves.ts +16 −7
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | |
| 8 | 8 | import { canRead } from "../../core/repos.ts"; |
| 9 | 9 | 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"; | |
| 11 | 12 | import { NO_WAVES } from "../../story/words.ts"; |
| 12 | 13 | import type { RepoSource } from "../../view/source.ts"; |
| 13 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -38,8 +39,8 @@ const plain = (s: string): string => | ||
| 38 | 39 | // eslint-disable-next-line no-control-regex |
| 39 | 40 | s.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]+/g, " "); |
| 40 | 41 | |
| 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[] { | |
| 43 | 44 | if (!entry.ok) return [plain(entry.file), "", `Not a wave: ${plain(entry.reason)}`]; |
| 44 | 45 | const w = entry.wave; |
| 45 | 46 | return [ |
| @@ -50,12 +51,17 @@ export function waveLines(entry: WaveEntry): string[] { | ||
| 50 | 51 | ...w.highlights.map((h) => ` ${plain(h.title)} - ${plain(h.why)}`), |
| 51 | 52 | "", |
| 52 | 53 | "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 | ])), | |
| 54 | 60 | ]; |
| 55 | 61 | } |
| 56 | 62 | |
| 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); | |
| 59 | 65 | const scroll = new Scroll(); |
| 60 | 66 | return { |
| 61 | 67 | title: entry.ok ? entry.wave.version : entry.file, |
| @@ -90,7 +96,10 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | ||
| 90 | 96 | const one = VERSION.test(version) |
| 91 | 97 | ? await readWave(f.dir(repo), repo.defaultBranch, version) |
| 92 | 98 | : 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)); | |
| 94 | 103 | }); |
| 95 | 104 | }; |
| 96 | 105 |
src/web/pages/waves.ts +20 −12
| @@ -13,7 +13,8 @@ import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../cor | ||
| 13 | 13 | import { repoDir } from "../../git/repos.ts"; |
| 14 | 14 | import type { Ctx } from "../../http/context.ts"; |
| 15 | 15 | 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"; | |
| 17 | 18 | import { NO_WAVES } from "../../story/words.ts"; |
| 18 | 19 | import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts"; |
| 19 | 20 | import { escapeHtml, html } from "../html.ts"; |
| @@ -135,9 +136,6 @@ ${entries.join("\n")} | ||
| 135 | 136 | }); |
| 136 | 137 | } |
| 137 | 138 | |
| 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 | ||
| 141 | 139 | // GET /:owner/:name/waves/:version |
| 142 | 140 | export async function wavePage(ctx: Ctx): Promise<Response> { |
| 143 | 141 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -151,6 +149,11 @@ export async function wavePage(ctx: Ctx): Promise<Response> { | ||
| 151 | 149 | ), |
| 152 | 150 | ); |
| 153 | 151 | 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 | : []; | |
| 154 | 157 | const rendering = performance.now(); |
| 155 | 158 | const scope = localScope(ctx, repo); |
| 156 | 159 | const back = html`<a href="${repoBase(repo)}/waves">All waves</a>`; |
| @@ -164,14 +167,19 @@ export async function wavePage(ctx: Ctx): Promise<Response> { | ||
| 164 | 167 | (h) => html`<li><strong>${h.title}</strong> — ${h.why}</li>`, |
| 165 | 168 | )} |
| 166 | 169 | </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. |
||
| 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 |
||
| 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 | )}` | |
| 175 | 183 | : html`<h1>${entry.file}</h1> |
| 176 | 184 | <p class="muted">Not a wave: ${entry.reason}</p>`; |
| 177 | 185 |
test/api-waves.test.ts +58 −6
| @@ -5,11 +5,13 @@ import { tmpdir } from "node:os"; | ||
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 8 | import type { Repo } from "../src/core/repos.ts"; | |
| 8 | 9 | import { createSession } from "../src/core/sessions.ts"; |
| 9 | 10 | import { createUser } from "../src/core/users.ts"; |
| 10 | 11 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | 12 | import { writeWave, type Wave } from "../src/story/wave.ts"; |
| 12 | 13 | import { quietLogs } from "./helpers/globals.ts"; |
| 14 | import { mergedPull, waveNaming } from "./helpers/wave-pulls.ts"; | |
| 13 | 15 | |
| 14 | 16 | quietLogs(); |
| 15 | 17 | |
| @@ -17,6 +19,9 @@ let dataDir: string; | ||
| 17 | 19 | let app: App; |
| 18 | 20 | let servers: Servers; |
| 19 | 21 | let cookie = ""; |
| 22 | let bobCookie = ""; | |
| 23 | let shown = 0; | |
| 24 | let secret = 0; | |
| 20 | 25 | const cells = process.env["FORGE_CELLS"]; |
| 21 | 26 | |
| 22 | 27 | const wave = (version: string, date: string, headline: string): Wave => ({ |
| @@ -52,12 +57,47 @@ beforeAll(async () => { | ||
| 52 | 57 | if (!made.ok) throw new Error(String(made.error)); |
| 53 | 58 | const alice = made.value; |
| 54 | 59 | 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}`; | |
| 55 | 69 | for (const [name, visibility] of [["pub", "public"], ["priv", "private"]] as const) { |
| 56 | 70 | const repo = await createRepo(app, alice, { name, visibility }); |
| 57 | 71 | if (!repo.ok) throw new Error(String(repo.error)); |
| 58 | 72 | const c = await commitToBranch(app, alice, repo.value, { message: "waves", files }); |
| 59 | 73 | if (!c.ok) throw new Error(JSON.stringify(c.error)); |
| 60 | 74 | } |
| 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)); | |
| 61 | 101 | }); |
| 62 | 102 | |
| 63 | 103 | afterAll(async () => { |
| @@ -68,8 +108,10 @@ afterAll(async () => { | ||
| 68 | 108 | await rm(dataDir, { recursive: true, force: true }); |
| 69 | 109 | }); |
| 70 | 110 | |
| 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 | }); | |
| 73 | 115 | const body = await res.text(); |
| 74 | 116 | const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]!); |
| 75 | 117 | 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 ( | ||
| 93 | 135 | const w = JSON.parse(got.body); |
| 94 | 136 | expect(w.headline).toBe("Ninth"); |
| 95 | 137 | 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/"); | |
| 99 | 151 | }); |
| 100 | 152 | |
| 101 | 153 | 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 () => { | ||
| 112 | 164 | |
| 113 | 165 | test("the reads name their Server-Timing phases", async () => { |
| 114 | 166 | 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"]); | |
| 116 | 168 | }); |
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"; | ||
| 14 | 14 | import { local } from "../src/tui/screens/where.ts"; |
| 15 | 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 16 | 16 | import { quietLogs } from "./helpers/globals.ts"; |
| 17 | import { mergedPull, waveNaming } from "./helpers/wave-pulls.ts"; | |
| 17 | 18 | import { settleApp } from "./helpers/tui.ts"; |
| 18 | 19 | import { fixtureSlot } from "./helpers/slot.ts"; |
| 19 | 20 | import { seedAdmin } from "./helpers/admin.ts"; |
| @@ -63,6 +64,7 @@ describe("waves in the terminal", () => { | ||
| 63 | 64 | let bob: User; |
| 64 | 65 | let pub: Repo; |
| 65 | 66 | let priv: Repo; |
| 67 | let mix: Repo; | |
| 66 | 68 | |
| 67 | 69 | beforeAll(async () => { |
| 68 | 70 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-waves-")); |
| @@ -75,8 +77,11 @@ describe("waves in the terminal", () => { | ||
| 75 | 77 | const p = await createRepo(forge, alice, { name: "pub", visibility: "public" }); |
| 76 | 78 | const q = await createRepo(forge, alice, { name: "priv", visibility: "private" }); |
| 77 | 79 | 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"); | |
| 78 | 82 | pub = p.value; |
| 79 | 83 | priv = q.value; |
| 84 | mix = m.value; | |
| 80 | 85 | const put = (repo: Repo, files: { path: string; content: string }[]) => |
| 81 | 86 | commitToBranch(forge, alice, repo, { message: "waves", files }); |
| 82 | 87 | await put(pub, [ |
| @@ -84,6 +89,19 @@ describe("waves in the terminal", () => { | ||
| 84 | 89 | { path: "waves/0.10.0.md", content: text(wave("0.10.0", "2026-02-01", "Tenth")) }, |
| 85 | 90 | { path: "waves/broken.md", content: "no front matter\n" }, |
| 86 | 91 | ]); |
| 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 | ]); | |
| 87 | 105 | await put(priv, [ |
| 88 | 106 | { path: "waves/1.0.0.md", content: text(wave("1.0.0", "2026-03-01", "Secret launch")) }, |
| 89 | 107 | ]); |
| @@ -142,8 +160,20 @@ describe("waves in the terminal", () => { | ||
| 142 | 160 | expect(h.text).toContain("Tenth"); |
| 143 | 161 | expect(h.text).toContain("0.10.0 2026-02-01"); |
| 144 | 162 | 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/"); | |
| 147 | 177 | }); |
| 148 | 178 | |
| 149 | 179 | test("enter on an unparsable row shows its reason", async () => { |
| @@ -154,7 +184,8 @@ describe("waves in the terminal", () => { | ||
| 154 | 184 | }); |
| 155 | 185 | |
| 156 | 186 | test("wave text carries no escape or control characters", () => { |
| 157 | const lines = waveLines({ | |
| 187 | const lines = waveLines( | |
| 188 | { | |
| 158 | 189 | ok: true, |
| 159 | 190 | file: "1.0.0.md", |
| 160 | 191 | wave: { |
| @@ -162,9 +193,11 @@ describe("waves in the terminal", () => { | ||
| 162 | 193 | date: "2026-01-01", |
| 163 | 194 | headline: "Hi\u001b[31m red\u0007\u202e", |
| 164 | 195 | highlights: [{ title: "T\u001b]0;x\u0007", why: "w\nz" }], |
| 165 | pulls: [{ title: "P", url: "https://x.example/\u001b[2J" }], | |
| 196 | pulls: [], | |
| 166 | 197 | }, |
| 167 | }); | |
| 198 | }, | |
| 199 | [{ owner: "o", name: "r", number: 1, title: "P\u001b[2J", mergedAt: null, state: "open" }], | |
| 200 | ); | |
| 168 | 201 | expect(lines.join("\n")).not.toMatch(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/); |
| 169 | 202 | }); |
| 170 | 203 |
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 <b>thing</b>"); | |
| 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"; | ||
| 8 | 8 | import { createSession } from "../src/core/sessions.ts"; |
| 9 | 9 | import { createUser, type User } from "../src/core/users.ts"; |
| 10 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 11 | import { webUrl } from "../src/web/pages/waves.ts"; | |
| 12 | 11 | import { writeWave, type Wave } from "../src/story/wave.ts"; |
| 13 | 12 | |
| 14 | 13 | const PASSWORD = "a-good-passphrase"; |
| @@ -156,7 +155,6 @@ describe("waves page", () => { | ||
| 156 | 155 | expect(body).toContain("2026-02-01"); |
| 157 | 156 | expect(body).toContain("Thing 1"); |
| 158 | 157 | expect(body).toContain("because"); |
| 159 | expect(body).toContain('href="https://forge.example/o/r/pulls/1"'); | |
| 160 | 158 | }); |
| 161 | 159 | |
| 162 | 160 | test("values are escaped and a javascript: pull is not a link", async () => { |
| @@ -166,8 +164,6 @@ describe("waves page", () => { | ||
| 166 | 164 | expect(body).not.toContain("<b>links"); |
| 167 | 165 | const js = await (await get("/alice/pub/waves/0.5.0")).text(); |
| 168 | 166 | expect(js).not.toContain('href="javascript:'); |
| 169 | expect(webUrl("javascript:alert(1)")).toBe(false); | |
| 170 | expect(webUrl("https://x.example/p/1")).toBe(true); | |
| 171 | 167 | }); |
| 172 | 168 | |
| 173 | 169 | test("an unknown or malformed version is a 404; an unparsable file shows its reason", async () => { |
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.pullsdirectly any more.