Public: anyone can read this; the crew can change it.
Cut a wave: an admin opens a draft waves/<next>.md as a pull listing every pull merged since the last wave #1800
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1627-cut-a-wave-an-admin-opens-a-draft-waves → main 2 commits fork 2 threads · 2 unresolved
8 files
changed +445
−1
README.md +7
| @@ -751,6 +751,13 @@ work here. | ||
| 751 | 751 | |
| 752 | 752 | ## What works today |
| 753 | 753 | |
| 754 | - **Cut a wave** — Settings → *Cut a wave* (repository admins, same-origin | |
| 755 | POST; web-only, the terminal's settings hand over to the web) opens a pull | |
| 756 | adding `waves/<next>.md`, listing the pulls merged since the last wave and | |
| 757 | not already in an earlier wave. The headline and highlights are `TODO` | |
| 758 | placeholders; no model is called. It refuses, writing nothing, when an | |
| 759 | earlier wave does not parse (naming the file) or nothing new has merged. | |
| 760 | ||
| 754 | 761 | - **Accounts & access** — register/sign in, personal access tokens with coarse |
| 755 | 762 | scopes (ro/rw/admin), public and private repositories. |
| 756 | 763 | - **Git** — push and pull over HTTPS **and SSH**, browse code (tree, blob with line anchors, |
scripts/gate.ts +1
| @@ -82,6 +82,7 @@ export const GATE: readonly string[] = [ | ||
| 82 | 82 | "test/review-from-green.test.ts", |
| 83 | 83 | "test/themes.test.ts", |
| 84 | 84 | "test/waves.test.ts", |
| 85 | "test/cut-wave.test.ts", | |
| 85 | 86 | "test/crew-wrapup.test.ts", |
| 86 | 87 | "test/crew-limits.test.ts", |
| 87 | 88 | "test/crew-parks.test.ts", |
src/actions/cut_wave.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * Cut a wave: an admin opens a draft `waves/<next>.md` as a pull listing | |
| 3 | * every pull merged since the last wave (`story/cut.ts` plans it). The | |
| 4 | * earlier waves are read from the default branch; one that does not parse | |
| 5 | * stops the cut, named, and nothing is written. No model is called. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { canAdmin, type Repo } from "../core/repos.ts"; | |
| 10 | import { mergedPullsSince } from "../core/pulls.ts"; | |
| 11 | import type { User } from "../core/users.ts"; | |
| 12 | import { listTree, readerFor } from "../git/read.ts"; | |
| 13 | import { repoDir } from "../git/repos.ts"; | |
| 14 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 15 | import { cutWave, type WaveFile } from "../story/cut.ts"; | |
| 16 | import { editFiles } from "./contents.ts"; | |
| 17 | ||
| 18 | export interface CutDone { | |
| 19 | readonly version: string; | |
| 20 | readonly number: number; | |
| 21 | } | |
| 22 | ||
| 23 | /** The waves on the default branch, as files. */ | |
| 24 | async function readWaves(app: App, repo: Repo): Promise<WaveFile[]> { | |
| 25 | const dir = repoDir(app.reposRoot, repo.ownerName, repo.name); | |
| 26 | const tree = await listTree(dir, repo.defaultBranch, "waves"); | |
| 27 | if (!tree.ok) return []; | |
| 28 | const files: WaveFile[] = []; | |
| 29 | for (const e of tree.value) { | |
| 30 | if (e.type !== "blob" || !e.name.endsWith(".md")) continue; | |
| 31 | const obj = await readerFor(dir).object(e.oid); | |
| 32 | const text = obj.ok ? new TextDecoder().decode(obj.value.body) : ""; | |
| 33 | files.push({ path: `waves/${e.name}`, text }); | |
| 34 | } | |
| 35 | return files; | |
| 36 | } | |
| 37 | ||
| 38 | /** The reason is a plain sentence, ready to show. */ | |
| 39 | export async function cutWaveFor( | |
| 40 | app: App, | |
| 41 | actor: User | undefined, | |
| 42 | repo: Repo, | |
| 43 | ): Promise<Result<CutDone, string>> { | |
| 44 | if (!actor || actor.kind === "agent" || !canAdmin(app.db, repo, actor)) | |
| 45 | return err("Only a repository admin can cut a wave."); | |
| 46 | const waves = await readWaves(app, repo); | |
| 47 | const base = app.config.url ?? `http://localhost:${app.config.port}`; | |
| 48 | const repoUrl = `${base.replace(/\/+$/, "")}/${repo.ownerName}/${repo.name}`; | |
| 49 | const plan = cutWave({ | |
| 50 | waves, | |
| 51 | merged: (since) => | |
| 52 | mergedPullsSince(app.db, repo.id, since === "" ? null : since).map((p) => ({ | |
| 53 | number: p.number, | |
| 54 | title: p.title, | |
| 55 | mergedAt: p.mergedAt ?? "", | |
| 56 | headBranch: p.headBranch, | |
| 57 | })), | |
| 58 | today: new Date().toISOString().slice(0, 10), | |
| 59 | link: (n) => `${repoUrl}/pulls/${n}`, | |
| 60 | }); | |
| 61 | if (!plan.ok) return err(plan.error); | |
| 62 | const { version, path, text, pulls } = plan.value; | |
| 63 | const edited = await editFiles(app, actor, repo, { | |
| 64 | newBranch: `wave/${version}`, | |
| 65 | message: `Wave ${version}: ${pulls} merged pulls`, | |
| 66 | files: [{ path, content: text }], | |
| 67 | openPull: { | |
| 68 | title: `Wave ${version}`, | |
| 69 | body: `A draft of ${path}, listing ${pulls} merged pull${pulls === 1 ? "" : "s"}. Write the headline and the highlights (the lines marked TODO) before merging.`, | |
| 70 | }, | |
| 71 | }); | |
| 72 | if (!edited.ok) { | |
| 73 | const e = edited.error; | |
| 74 | return err( | |
| 75 | typeof e === "string" | |
| 76 | ? "The wave could not be opened." | |
| 77 | : e.kind === "branch_failed" | |
| 78 | ? `The branch wave/${version} could not be made; a draft of this wave may already be open.` | |
| 79 | : "The wave could not be opened.", | |
| 80 | ); | |
| 81 | } | |
| 82 | if (!edited.value.pull) return err("The wave could not be opened."); | |
| 83 | return ok({ version, number: edited.value.pull.number }); | |
| 84 | } |
src/core/pulls.ts +26
| @@ -967,3 +967,29 @@ export function landingTrailers( | ||
| 967 | 967 | line("Co-authored-by", lander), |
| 968 | 968 | ].join("\n"); |
| 969 | 969 | } |
| 970 | ||
| 971 | /** | |
| 972 | * Every pull of a repository merged at or after an instant, oldest merge | |
| 973 | * first — what a wave lists. Sealed reports' pulls are left out: a wave | |
| 974 | * is public text. `since` null means from the beginning. | |
| 975 | */ | |
| 976 | export function mergedPullsSince( | |
| 977 | db: Database, | |
| 978 | repoId: string, | |
| 979 | since: string | null, | |
| 980 | ): Pull[] { | |
| 981 | const ids = db | |
| 982 | .query<{ issue_id: string }, [string, string]>( | |
| 983 | `SELECT pull.issue_id FROM pull JOIN issue ON issue.id = pull.issue_id | |
| 984 | WHERE issue.repo_id = ?1 AND issue.sealed = 0 | |
| 985 | AND pull.merged_at IS NOT NULL AND pull.merged_at >= ?2 | |
| 986 | ORDER BY pull.merged_at ASC, issue.number ASC`, | |
| 987 | ) | |
| 988 | .all(repoId, since ?? "") | |
| 989 | .map((r) => r.issue_id); | |
| 990 | const issues = getIssuesByIds(db, ids); | |
| 991 | return joinMany( | |
| 992 | db, | |
| 993 | ids.map((id) => issues.get(id)).filter((i): i is Issue => i !== undefined), | |
| 994 | ); | |
| 995 | } |
src/http/server.ts +2
| @@ -323,6 +323,7 @@ import { | ||
| 323 | 323 | repoDelete, |
| 324 | 324 | settingsPage, |
| 325 | 325 | settingsUpdate, |
| 326 | waveCut, | |
| 326 | 327 | webhookCreate, |
| 327 | 328 | webhookDelete, |
| 328 | 329 | } from "../web/pages/settings.ts"; |
| @@ -1133,6 +1134,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1133 | 1134 | // Settings (owner-gated inside the handlers). |
| 1134 | 1135 | router.add("GET", "/:owner/:name/settings", settingsPage); |
| 1135 | 1136 | router.add("POST", "/:owner/:name/settings", settingsUpdate); |
| 1137 | router.add("POST", "/:owner/:name/settings/wave", waveCut); | |
| 1136 | 1138 | router.add("GET", "/:owner/:name/settings/app", appSettingsPage); |
| 1137 | 1139 | router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate); |
| 1138 | 1140 | router.add("POST", "/:owner/:name/settings/app/disable", appDisable); |
src/story/cut.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** | |
| 2 | * Cutting a wave: from the waves already in the repository and the pulls | |
| 3 | * merged since the last one, the next `waves/<version>.md` as a draft. Pure: | |
| 4 | * plain data in, plain data out. The headline and highlights are marked | |
| 5 | * placeholders for a person to write; no model is asked. | |
| 6 | */ | |
| 7 | ||
| 8 | import { | |
| 9 | MIN_HIGHLIGHTS, | |
| 10 | parseWave, | |
| 11 | wavePath, | |
| 12 | writeWave, | |
| 13 | type Wave, | |
| 14 | } from "./wave.ts"; | |
| 15 | ||
| 16 | export const PLACEHOLDER = "TODO"; | |
| 17 | ||
| 18 | export interface WaveFile { | |
| 19 | /** `waves/<name>.md`. */ | |
| 20 | path: string; | |
| 21 | text: string; | |
| 22 | } | |
| 23 | export interface MergedPull { | |
| 24 | number: number; | |
| 25 | title: string; | |
| 26 | mergedAt: string; | |
| 27 | /** The head branch, so a wave's own pull is not listed in the next. */ | |
| 28 | headBranch: string; | |
| 29 | } | |
| 30 | export interface Cut { | |
| 31 | version: string; | |
| 32 | path: string; | |
| 33 | text: string; | |
| 34 | pulls: number; | |
| 35 | } | |
| 36 | export type CutResult = { ok: true; value: Cut } | { ok: false; error: string }; | |
| 37 | ||
| 38 | const parts = (v: string): number[] => | |
| 39 | v.replace(/-.*$/, "").split(".").map(Number); | |
| 40 | ||
| 41 | const newer = (a: Wave, b: Wave): boolean => { | |
| 42 | const x = parts(a.version); | |
| 43 | const y = parts(b.version); | |
| 44 | for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return (x[i] ?? 0) > (y[i] ?? 0); | |
| 45 | return a.date > b.date; | |
| 46 | }; | |
| 47 | ||
| 48 | /** A title that survives the wave's link syntax. */ | |
| 49 | const linkTitle = (n: number, title: string): string => | |
| 50 | `#${n} ${title.replace(/[\[\]]/g, "").replace(/\s+/g, " ").trim()}`; | |
| 51 | ||
| 52 | /** | |
| 53 | * Plan the next wave. `link` makes a pull's address; `today` is YYYY-MM-DD. | |
| 54 | * Every earlier wave must parse: the first that does not stops the cut, | |
| 55 | * named, with nothing written. | |
| 56 | */ | |
| 57 | export function cutWave(input: { | |
| 58 | waves: readonly WaveFile[]; | |
| 59 | /** The pulls merged at or after a date (YYYY-MM-DD, "" for all). */ | |
| 60 | merged: (since: string) => readonly MergedPull[]; | |
| 61 | today: string; | |
| 62 | link: (number: number) => string; | |
| 63 | }): CutResult { | |
| 64 | let last: Wave | undefined; | |
| 65 | const listed = new Set<number>(); | |
| 66 | for (const f of input.waves) { | |
| 67 | const r = parseWave(f.path, f.text); | |
| 68 | if (!r.ok) return { ok: false, error: r.error.message }; | |
| 69 | for (const p of r.value.pulls) { | |
| 70 | const n = /\/pulls\/(\d+)$/.exec(p.url)?.[1] ?? /^#(\d+) /.exec(p.title)?.[1]; | |
| 71 | if (n !== undefined) listed.add(Number(n)); | |
outdated
High: comparing day strings with |
||
| 72 | } | |
| 73 | if (last === undefined || newer(r.value, last)) last = r.value; | |
| 74 | } | |
| 75 | const since = last?.date ?? ""; | |
| 76 | // A pull merged on the last wave's own day may be in it already (or may | |
| 77 | // have landed after the cut): the date bounds the query, the list decides. | |
| 78 | const pulls = input.merged(since) | |
| 79 | .filter((p) => p.mergedAt.slice(0, 10) >= since && !listed.has(p.number)) | |
| 80 | .filter((p) => !p.headBranch.startsWith("wave/")) | |
| 81 | .sort((a, b) => a.mergedAt.localeCompare(b.mergedAt) || a.number - b.number); | |
| 82 | if (pulls.length === 0) | |
| 83 | return { | |
| 84 | ok: false, | |
| 85 | error: last | |
| 86 | ? `Nothing has been merged since the last wave (${last.version}, ${last.date}).` | |
| 87 | : "Nothing has been merged yet.", | |
| 88 | }; | |
| 89 | const p = last ? parts(last.version) : [0, 0, 0]; | |
| 90 | const version = last ? `${p[0]}.${p[1]}.${(p[2] ?? 0) + 1}` : "0.1.0"; | |
| 91 | const wave: Wave = { | |
| 92 | version, | |
| 93 | date: input.today, | |
| 94 | headline: `${PLACEHOLDER}: one line for this wave`, | |
| 95 | highlights: Array.from({ length: MIN_HIGHLIGHTS }, (_, i) => ({ | |
| 96 | title: `${PLACEHOLDER}: highlight ${i + 1}`, | |
| 97 | why: `${PLACEHOLDER}: why it matters`, | |
| 98 | })), | |
| 99 | pulls: pulls.map((x) => ({ | |
| 100 | title: linkTitle(x.number, x.title), | |
| 101 | url: input.link(x.number), | |
| 102 | })), | |
| 103 | }; | |
| 104 | const text = writeWave(wave); | |
| 105 | if (!text.ok) return { ok: false, error: text.error.message }; | |
| 106 | return { | |
| 107 | ok: true, | |
| 108 | value: { version, path: wavePath(version), text: text.value, pulls: pulls.length }, | |
| 109 | }; | |
| 110 | } |
src/web/pages/settings.ts +29 −1
| @@ -100,6 +100,7 @@ import { | ||
| 100 | 100 | time, |
| 101 | 101 | timeAgo, |
| 102 | 102 | } from "../ui.ts"; |
| 103 | import { cutWaveFor } from "../../actions/cut_wave.ts"; | |
| 103 | 104 | import { secretsSection } from "./secrets.ts"; |
| 104 | 105 | import { saveGrants } from "./subscriptions.ts"; |
| 105 | 106 | |
| @@ -308,12 +309,23 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 308 | 309 | { head: html`${icon("branch")}<h2>Branch and gates</h2>` }, |
| 309 | 310 | ); |
| 310 | 311 | const secrets = secretsSection(ctx, "repo", `${repo.ownerName}/${repo.name}`); |
| 312 | const wave = card( | |
| 313 | html`<form method="post" action="${base}/settings/wave"> | |
| 314 | <p class="muted"> | |
| 315 | Opens a pull that adds the next <code>waves/<version>.md</code>, | |
| 316 | listing every pull merged since the last wave. The headline and | |
| 317 | highlights are left as TODO for you to write. | |
| 318 | </p> | |
| 319 | <div class="form-actions">${btn("Cut a wave", { variant: "primary" })}</div> | |
| 320 | </form>`, | |
| 321 | { head: html`${icon("rocket")}<h2>Cut a wave</h2>` }, | |
| 322 | ); | |
| 311 | 323 | return settingsPageResponse( |
| 312 | 324 | ctx, |
| 313 | 325 | repo, |
| 314 | 326 | "general", |
| 315 | 327 | "Settings", |
| 316 | html`${lead}<div class="mt">${body}</div>${secrets | |
| 328 | html`${lead}<div class="mt">${body}</div><div class="mt">${wave}</div>${secrets | |
| 317 | 329 | ? html`<div class="mt">${secrets}</div>` |
| 318 | 330 | : null}`, |
| 319 | 331 | ); |
| @@ -369,6 +381,22 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 369 | 381 | return flashTo(ctx, `${base}/settings`, flash); |
| 370 | 382 | } |
| 371 | 383 | |
| 384 | /** POST /:owner/:name/settings/wave — open the draft of the next wave. */ | |
| 385 | export async function waveCut(ctx: Ctx): Promise<Response> { | |
| 386 | const gate = await adminForm(ctx); | |
| 387 | if (gate instanceof Response) return gate; | |
| 388 | const { repo } = gate; | |
| 389 | const cut = await cutWaveFor(ctx.app, ctx.auth.user, repo); | |
| 390 | if (!cut.ok) | |
| 391 | return flashTo(ctx, `${repoBase(repo)}/settings`, { | |
| 392 | tone: "danger", | |
| 393 | message: cut.error, | |
| 394 | }); | |
| 395 | return flashTo(ctx, `${repoBase(repo)}/pulls/${cut.value.number}`, { | |
| 396 | message: `Wave ${cut.value.version} is open as a draft.`, | |
| 397 | }); | |
| 398 | } | |
| 399 | ||
| 372 | 400 | // ---- app ---- |
| 373 | 401 | |
| 374 | 402 | interface AppValues { |
test/cut-wave.test.ts added +186
| @@ -0,0 +1,186 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 6 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { getPull } from "../src/core/pulls.ts"; | |
| 9 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 10 | import { getUserByName } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { cutWave } from "../src/story/cut.ts"; | |
| 13 | import { parseWave, writeWave } from "../src/story/wave.ts"; | |
| 14 | ||
| 15 | const link = (n: number) => `https://f.test/o/r/pulls/${n}`; | |
| 16 | const merged = (number: number, mergedAt: string, title = `Pull ${number}`) => ({ | |
| 17 | number, | |
| 18 | title, | |
| 19 | mergedAt, | |
| 20 | headBranch: `b${number}`, | |
| 21 | }); | |
| 22 | const earlier = (version: string, date: string, listed: number[] = []) => { | |
| 23 | const w = writeWave({ | |
| 24 | version, | |
| 25 | date, | |
| 26 | headline: "h", | |
| 27 | highlights: [1, 2, 3].map((i) => ({ title: `t${i}`, why: "w" })), | |
| 28 | pulls: listed.map((n) => ({ title: `#${n} Pull ${n}`, url: link(n) })), | |
| 29 | }); | |
| 30 | if (!w.ok) throw w.error; | |
| 31 | return { path: `waves/${version}.md`, text: w.value }; | |
| 32 | }; | |
| 33 | ||
| 34 | describe("cutWave (pure)", () => { | |
| 35 | test("lists the pulls merged since the last wave, marks placeholders", () => { | |
| 36 | const r = cutWave({ | |
| 37 | waves: [earlier("0.1.0", "2026-10-01"), earlier("0.1.1", "2026-10-10")], | |
| 38 | merged: () => [ | |
| 39 | merged(1, "2026-10-02T00:00:00Z"), | |
| 40 | merged(7, "2026-10-12T08:00:00Z", "Late [one]"), | |
| 41 | merged(5, "2026-10-10T08:00:00Z"), | |
| 42 | { ...merged(9, "2026-10-13T00:00:00Z"), headBranch: "wave/0.1.1" }, | |
| 43 | ], | |
| 44 | today: "2026-10-15", | |
| 45 | link, | |
| 46 | }); | |
| 47 | expect(r.ok).toBe(true); | |
| 48 | if (!r.ok) return; | |
| 49 | expect(r.value.path).toBe("waves/0.1.2.md"); | |
| 50 | const parsed = parseWave(r.value.path, r.value.text); | |
| 51 | expect(parsed.ok).toBe(true); | |
| 52 | if (!parsed.ok) return; | |
| 53 | expect(parsed.value.pulls.map((p) => p.url)).toEqual([link(5), link(7)]); | |
| 54 | expect(parsed.value.pulls[1]!.title).toBe("#7 Late one"); | |
| 55 | expect(parsed.value.headline).toContain("TODO"); | |
| 56 | expect(parsed.value.highlights.every((h) => h.title.includes("TODO"))).toBe(true); | |
| 57 | }); | |
| 58 | ||
| 59 | test("a pull the last wave already lists is not listed again", () => { | |
| 60 | const r = cutWave({ | |
| 61 | waves: [earlier("0.1.0", "2026-10-10", [5])], | |
| 62 | merged: () => [merged(5, "2026-10-10T08:00:00Z"), merged(6, "2026-10-10T18:00:00Z")], | |
| 63 | today: "2026-10-15", | |
| 64 | link, | |
| 65 | }); | |
| 66 | expect(r.ok).toBe(true); | |
| 67 | if (r.ok) expect(r.value.text).toContain("/pulls/6)"); | |
| 68 | if (r.ok) expect(r.value.text).not.toContain("/pulls/5)"); | |
| 69 | }); | |
| 70 | ||
| 71 | test("a wave whose only new pull was already listed is refused", () => { | |
| 72 | const r = cutWave({ | |
| 73 | waves: [earlier("0.1.0", "2026-10-10", [5])], | |
| 74 | merged: () => [merged(5, "2026-10-10T08:00:00Z")], | |
| 75 | today: "2026-10-15", | |
| 76 | link, | |
| 77 | }); | |
| 78 | expect(r.ok).toBe(false); | |
| 79 | }); | |
| 80 | ||
| 81 | test("a malformed earlier wave stops the cut and is named", () => { | |
| 82 | const r = cutWave({ | |
| 83 | waves: [earlier("0.1.0", "2026-10-01"), { path: "waves/0.2.0.md", text: "junk" }], | |
| 84 | merged: () => [merged(1, "2026-10-14T00:00:00Z")], | |
| 85 | today: "2026-10-15", | |
| 86 | link, | |
| 87 | }); | |
| 88 | expect(r.ok).toBe(false); | |
| 89 | if (!r.ok) expect(r.error).toContain("waves/0.2.0.md"); | |
| 90 | }); | |
| 91 | ||
| 92 | test("nothing merged since the last wave is refused plainly", () => { | |
| 93 | const r = cutWave({ | |
| 94 | waves: [earlier("0.1.0", "2026-10-10")], | |
| 95 | merged: () => [merged(1, "2026-10-09T23:59:59Z")], | |
| 96 | today: "2026-10-15", | |
| 97 | link, | |
| 98 | }); | |
| 99 | expect(r).toEqual({ | |
| 100 | ok: false, | |
| 101 | error: "Nothing has been merged since the last wave (0.1.0, 2026-10-10).", | |
| 102 | }); | |
| 103 | }); | |
| 104 | }); | |
| 105 | ||
| 106 | describe("Cut a wave in Settings", () => { | |
| 107 | let dataDir: string; | |
| 108 | let app: App; | |
| 109 | let servers: Servers; | |
| 110 | let base: string; | |
| 111 | let cookies: Record<string, string> = {}; | |
| 112 | let repo: Repo; | |
| 113 | ||
| 114 | const signup = async (username: string) => { | |
| 115 | const res = await fetch(`${base}/signup`, { | |
| 116 | method: "POST", | |
| 117 | body: new URLSearchParams({ username, email: `${username}@f.dev`, password: "cut-wave-password" }), | |
| 118 | redirect: "manual", | |
| 119 | }); | |
| 120 | return (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 121 | }; | |
| 122 | const cut = (who: string, headers: Record<string, string> = {}) => | |
| 123 | fetch(`${base}/olive/proj/settings/wave`, { | |
| 124 | method: "POST", | |
| 125 | headers: { cookie: cookies[who]!, ...headers }, | |
| 126 | body: new URLSearchParams(), | |
| 127 | redirect: "manual", | |
| 128 | }); | |
| 129 | ||
| 130 | beforeAll(async () => { | |
| 131 | dataDir = await mkdtemp(join(tmpdir(), "forge-cutwave-")); | |
| 132 | app = createApp({ dataDir, port: 0 }); | |
| 133 | servers = await serve(app); | |
| 134 | base = servers.url; | |
| 135 | cookies = { olive: await signup("olive"), stranger: await signup("stranger") }; | |
| 136 | await fetch(`${base}/new`, { | |
| 137 | method: "POST", | |
| 138 | headers: { cookie: cookies.olive! }, | |
| 139 | body: new URLSearchParams({ name: "proj", visibility: "public", description: "x" }), | |
| 140 | redirect: "manual", | |
| 141 | }); | |
| 142 | repo = getRepo(app.db, "olive", "proj")!; | |
| 143 | }); | |
| 144 | afterAll(async () => { | |
| 145 | servers.stop(); | |
| 146 | app.close(); | |
| 147 | await rm(dataDir, { recursive: true, force: true }); | |
| 148 | }); | |
| 149 | ||
| 150 | test("refused for a non-admin and cross-origin, writing nothing", async () => { | |
| 151 | expect((await cut("stranger")).status).toBe(404); | |
| 152 | expect((await cut("olive", { origin: "http://evil.example" })).status).toBe(403); | |
| 153 | expect(app.db.query("SELECT COUNT(*) AS n FROM issue WHERE kind = 'pull'").get()).toEqual({ n: 0 }); | |
| 154 | }); | |
| 155 | ||
| 156 | test("an admin's cut opens a pull listing the merged pulls; a second finds nothing new", async () => { | |
| 157 | const olive = getUserByName(app.db, "olive")!; | |
| 158 | await commitToBranch(app, olive, repo, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 159 | // Nothing merged yet: refused with a reason, no pull. | |
| 160 | const none = await cut("olive"); | |
| 161 | expect(none.status).toBe(303); | |
| 162 | expect(none.headers.get("location")).toBe("/olive/proj/settings"); | |
| 163 | expect(decodeURIComponent(none.headers.get("set-cookie") ?? "")).toContain("Nothing has been merged yet"); | |
| 164 | for (const n of ["one", "two"]) { | |
| 165 | await commitToBranch(app, olive, repo, { branch: n, message: n, files: [{ path: `${n}.txt`, content: n }] }); | |
| 166 | const p = await openPull(app, olive, repo, { title: `Feature ${n}`, baseBranch: "main", headBranch: n }); | |
| 167 | if (!p.ok) throw new Error(p.error); | |
| 168 | expect((await mergePull(app, olive, repo, p.value, "merge")).ok).toBe(true); | |
| 169 | } | |
| 170 | const res = await cut("olive"); | |
| 171 | expect(res.status).toBe(303); | |
| 172 | const to = res.headers.get("location")!; | |
| 173 | expect(to).toMatch(/^\/olive\/proj\/pulls\/\d+$/); | |
| 174 | const pull = getPull(app.db, repo.id, Number(to.split("/").pop()))!; | |
| 175 | expect(pull.headBranch).toBe("wave/0.1.0"); | |
| 176 | const file = await Bun.spawn(["git", "show", "wave/0.1.0:waves/0.1.0.md"], { | |
| 177 | cwd: join(dataDir, "repos", "olive", "proj.git"), | |
| 178 | stdout: "pipe", | |
| 179 | }); | |
| 180 | const text = await new Response(file.stdout).text(); | |
| 181 | const parsed = parseWave("waves/0.1.0.md", text); | |
| 182 | expect(parsed.ok).toBe(true); | |
| 183 | if (parsed.ok) | |
| 184 | expect(parsed.value.pulls.map((p) => p.title)).toEqual(["#1 Feature one", "#2 Feature two"]); | |
| 185 | }); | |
| 186 | }); |
new above
offline
This loads the whole merged history on every cut. Pass the boundary into
mergedPullsSinceso the query filters, not the loop.