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

Merged opened this

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

Merged

scout/forge:scout/1627-cut-a-wave-an-admin-opens-a-draft-waves → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/1627-cut-a-wave-an-admin-opens-a-draft-waves was merged into main as b68f803.

Review

Sign in to review this change.
8 files changed +445 −1
README.md +7
@@ -751,6 +751,13 @@ work here.
751751
752752 ## What works today
753753
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
754761 - **Accounts & access** — register/sign in, personal access tokens with coarse
755762 scopes (ro/rw/admin), public and private repositories.
756763 - **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[] = [
8282 "test/review-from-green.test.ts",
8383 "test/themes.test.ts",
8484 "test/waves.test.ts",
85 "test/cut-wave.test.ts",
8586 "test/crew-wrapup.test.ts",
8687 "test/crew-limits.test.ts",
8788 "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) =>
outdated

This loads the whole merged history on every cut. Pass the boundary into mergedPullsSince so the query filters, not the loop.

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(
967967 line("Co-authored-by", lander),
968968 ].join("\n");
969969 }
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 {
323323 repoDelete,
324324 settingsPage,
325325 settingsUpdate,
326 waveCut,
326327 webhookCreate,
327328 webhookDelete,
328329 } from "../web/pages/settings.ts";
@@ -1133,6 +1134,7 @@ export function buildRouter(): Router<Handler> {
11331134 // Settings (owner-gated inside the handlers).
11341135 router.add("GET", "/:owner/:name/settings", settingsPage);
11351136 router.add("POST", "/:owner/:name/settings", settingsUpdate);
1137 router.add("POST", "/:owner/:name/settings/wave", waveCut);
11361138 router.add("GET", "/:owner/:name/settings/app", appSettingsPage);
11371139 router.add("POST", "/:owner/:name/settings/app", appSettingsUpdate);
11381140 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 >= puts every pull merged on the last wave's own date back into the next wave, including the ones that wave already listed. Exclude the pull numbers earlier waves already list (from the parsed pulls), and add a test for it. The current test asserts #5 (merged 2026-10-10) in the wave after 0.1.1 (dated 2026-10-10), which encodes the bug.

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 {
100100 time,
101101 timeAgo,
102102 } from "../ui.ts";
103 import { cutWaveFor } from "../../actions/cut_wave.ts";
103104 import { secretsSection } from "./secrets.ts";
104105 import { saveGrants } from "./subscriptions.ts";
105106
@@ -308,12 +309,23 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
308309 { head: html`${icon("branch")}<h2>Branch and gates</h2>` },
309310 );
310311 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/&lt;version&gt;.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 );
311323 return settingsPageResponse(
312324 ctx,
313325 repo,
314326 "general",
315327 "Settings",
316 html`${lead}<div class="mt">${body}</div>${secrets
328 html`${lead}<div class="mt">${body}</div><div class="mt">${wave}</div>${secrets
317329 ? html`<div class="mt">${secrets}</div>`
318330 : null}`,
319331 );
@@ -369,6 +381,22 @@ export async function settingsUpdate(ctx: Ctx): Promise<Response> {
369381 return flashTo(ctx, `${base}/settings`, flash);
370382 }
371383
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
372400 // ---- app ----
373401
374402 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs