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

A wave opens as headline, then its changes, then each change's pull, one fold each, and the page says how far its milestone is #11292

Merged opened this

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

Merged

mason/forge:mason/11234-a-wave-opens-as-headline-then-its-change → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/11234-a-wave-opens-as-headline-then-its-change was merged into main as e3fa0d8.

Review

Sign in to review this change.
8 files changed +262 −29
docs/features/wave-walk.md +4
@@ -11,6 +11,10 @@ A wave names the pulls it carries. You can go from the wave to a pull, to its co
1111
1212 The `from` value is checked against the shape of a wave address before it is used; anything else shows no breadcrumb.
1313
14 ## How far the wave got
15
16 When a wave's pulls closed issues under a milestone, the wave opens as its headline, then one progress line (`<milestone>: 1 of 2 done`, the widest milestone it moved), then the highlights. Under **Moved** each change is a closed fold: tap it to see the pulls of this wave that moved it, and a link to the milestone. Pulls no fold holds (another repository's, or ones that closed no milestone issue) are listed under **Pulls**; each pull is said once. A wave that moved no milestone has no progress line. The MCP `waves` tool says the same line as `progress`, and the terminal prints it under the date, with each moved milestone's pulls beneath it. Tested in `test/wave-folds.test.ts` and `test/tui-waves.test.ts`.
17
1418 ## In the terminal
1519
1620 1. Open a repository's Waves screen and press Enter on a wave.
src/http/mcp/tools/waves.ts +8 −2
@@ -1,11 +1,13 @@
1 import { readWave, readWaves } from "../../../story/waves.ts";
1 import { pullRefs, readWave, readWaves } from "../../../story/waves.ts";
22 import { VERSION as WAVE_VERSION } from "../../../story/wave.ts";
33 import { repoDir } from "../../../git/repos.ts";
4 import { movedForViewer, waveProgress } from "../../../story/wave_moved.ts";
5 import { planNow } from "../../../story/wave_plan.ts";
outdated

Duplicate import from waves.ts; merge with the first line.

46 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
57
68 export const tool: Tool = {
79 name: "waves",
8 doc: "`{ repo, version? }`: the repository's waves newest first (version, date, headline; a broken file is `{ version, error }`), or with `version` one wave: headline, highlights with their why, pull numbers (with the `ui://forge/wave` card); a private repository is not found to a stranger",
10 doc: "`{ repo, version? }`: the repository's waves newest first (version, date, headline; a broken file is `{ version, error }`), or with `version` one wave: headline, progress, highlights with their why, pull numbers (with the `ui://forge/wave` card); a private repository is not found to a stranger",
911 description: "Waves; `version`: one.",
1012 inputSchema: {
1113 type: "object",
@@ -36,12 +38,16 @@ export const tool: Tool = {
3638 if (entry === null) return bad("not_found", `no wave ${version}`);
3739 if (!entry.ok) return bad("invalid", entry.reason);
3840 const w = entry.wave;
41 const progress = waveProgress(
42 movedForViewer({ db: ctx.app.db, repo, refs: pullRefs(w), viewer: user, plan: () => planNow(ctx.app.db, repo, new Date()) }),
43 );
3944 return good({
4045 repo: repo.ownerName + "/" + repo.name,
4146 wave: {
4247 version: w.version,
4348 date: w.date,
4449 headline: w.headline,
50 ...(progress === "" ? {} : { progress }),
4551 highlights: w.highlights,
4652 pulls: w.pulls.flatMap((p) => {
4753 const n = /\/pulls\/(\d+)\/?$/.exec(p.url);
src/story/wave_moved.ts +66 −4
@@ -22,6 +22,54 @@ export interface MovedMilestone {
2222 total: number;
2323 }
2424
25 /** The words for how far a milestone is; the page, the card and the API say it alike. */
26 export const doneWords = (m: Pick<MovedMilestone, "done" | "total">): string => `${m.done} of ${m.total} done`;
27
28 /**
29 * The one progress line a wave opens with: its widest moved milestone, named
30 * with how far it is. Empty when the wave moved none, so no line is drawn.
31 */
32 export function waveProgress(moved: readonly MovedMilestone[]): string {
33 let widest: MovedMilestone | undefined;
34 for (const m of moved) if (widest === undefined || m.total > widest.total) widest = m;
35 return widest === undefined ? "" : `${widest.title}: ${doneWords(widest)}`;
36 }
37
38 /**
39 * Which of the wave's pulls (by number) carry each moved milestone: a pull
40 * belongs to a milestone that waits, however far up, for an issue it closes.
41 */
42 export function pullsByMilestone(input: {
43 pulls: readonly number[];
44 closes: ReadonlyMap<number, readonly number[]>;
45 plan: Pick<Plan, "read">;
46 }): Map<number, number[]> {
47 const { graph, tree } = input.plan.read;
48 const byNumber = new Map(graph.issues.map((i) => [i.number, i.id]));
49 const numberOf = new Map(graph.issues.map((i) => [i.id, i.number]));
50 const out = new Map<number, number[]>();
51 for (const pull of new Set(input.pulls)) {
52 const seen = new Set<string>();
53 const queue: string[] = [];
54 for (const n of input.closes.get(pull) ?? []) {
55 const id = byNumber.get(n);
56 if (id !== undefined && !seen.has(id)) {
57 seen.add(id);
58 queue.push(id);
59 }
60 }
61 for (let at = 0; at < queue.length; at++)
62 for (const waiter of tree.waitedBy.get(queue[at]!) ?? []) {
63 if (seen.has(waiter)) continue;
64 seen.add(waiter);
65 queue.push(waiter);
66 const n = graph.structural.has(waiter) ? undefined : numberOf.get(waiter);
67 if (n !== undefined) out.set(n, [...(out.get(n) ?? []), pull]);
68 }
69 }
70 return out;
71 }
72
2573 /**
2674 * `closes` maps a pull's number to the issue numbers it closes; `pulls` names
2775 * the wave's pulls in this repository. Milestones come back by number.
@@ -80,6 +128,17 @@ export function movedForViewer(input: {
80128 viewer: User | undefined;
81129 plan: () => Plan;
82130 }): MovedMilestone[] {
131 return movedWithPulls(input).map(({ pulls: _pulls, ...m }) => m);
132 }
133
134 /** `movedForViewer`, each milestone with the wave's pulls (numbers) that moved it. */
135 export function movedWithPulls(input: {
136 db: Database;
137 repo: Repo;
138 refs: readonly PullRef[];
139 viewer: User | undefined;
140 plan: () => Plan;
141 }): (MovedMilestone & { pulls: number[] })[] {
83142 const { db, repo } = input;
84143 const here = input.refs
85144 .filter((r) => r.owner === repo.ownerName && r.name === repo.name)
@@ -88,8 +147,11 @@ export function movedForViewer(input: {
88147 if ([...closes.values()].every((c) => c.length === 0)) return [];
89148 const plan = input.plan();
90149 const issues = new Map(plan.read.graph.issues.map((i) => [i.number, i]));
91 return movedMilestones({ pulls: here, closes, plan }).filter((m) => {
92 const issue = issues.get(m.number);
93 return issue !== undefined && mayView(db, repo, issue, input.viewer);
94 });
150 const carried = pullsByMilestone({ pulls: here, closes, plan });
151 return movedMilestones({ pulls: here, closes, plan })
152 .filter((m) => {
153 const issue = issues.get(m.number);
154 return issue !== undefined && mayView(db, repo, issue, input.viewer);
155 })
156 .map((m) => ({ ...m, pulls: carried.get(m.number) ?? [] }));
95157 }
src/tui/screens/waves.ts +8 −3
@@ -8,7 +8,7 @@
88 import { cutWaveFor, planCutFor } from "../../actions/cut_wave.ts";
99 import { canRead, getRepo } from "../../core/repos.ts";
1010 import { VERSION } from "../../story/wave.ts";
11 import { movedForViewer, type MovedMilestone } from "../../story/wave_moved.ts";
11 import { movedWithPulls, waveProgress, type MovedMilestone } from "../../story/wave_moved.ts";
1212 import { milestonePath, planNow } from "../../story/wave_plan.ts";
1313 import { repoBase } from "../../web/chrome.ts";
1414 import { carriedPulls, pullPath, type CarriedPull } from "../../story/wave_pulls.ts";
@@ -45,7 +45,7 @@ const plain = (s: string): string =>
4545 s.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]+/g, " ");
4646
4747 /** A moved milestone with the path that opens it. */
48 export type MovedLine = MovedMilestone & { path: string };
48 export type MovedLine = MovedMilestone & { path: string; pulls?: number[] };
4949
5050 /** One wave as lines of plain text: headline, version and date, highlights with why, then the pulls the viewer may see. */
5151 export function waveLines(
@@ -60,6 +60,7 @@ export function waveLines(
6060 return [
6161 plain(w.headline),
6262 `${plain(w.version)} ${plain(w.date)}`,
63 ...(moved.length === 0 ? [] : [plain(waveProgress(moved))]),
6364 "",
6465 "Highlights",
6566 ...w.highlights.map((h) => ` ${plain(h.title)} - ${plain(h.why)}`),
@@ -71,6 +72,10 @@ export function waveLines(
7172 ...moved.flatMap((m) => [
7273 ` ${plain(m.title)} - ${m.done} of ${m.total} done`,
7374 ` ${plain(m.path)}`,
75 ...(m.pulls ?? []).flatMap((n) => {
76 const p = pulls.find((x) => x.number === n);
77 return p === undefined ? [] : [` #${p.number} ${plain(p.title)}`];
78 }),
7479 ]),
7580 "",
7681 ]),
@@ -170,7 +175,7 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
170175 const pulls = one.ok ? carriedPulls(f.db, pullRefs(one.wave), f.user) : [];
171176 // Same list as the page, from the same function, with the same gate.
172177 const moved = one.ok
173 ? movedForViewer({
178 ? movedWithPulls({
174179 db: f.db,
175180 repo,
176181 refs: pullRefs(one.wave),
src/web/pages/waves.ts +35 −20
@@ -14,16 +14,16 @@ import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../cor
1414 import { repoDir } from "../../git/repos.ts";
1515 import type { Ctx } from "../../http/context.ts";
1616 import { VERSION, type Wave } from "../../story/wave.ts";
17 import { movedForViewer } from "../../story/wave_moved.ts";
17 import { movedWithPulls, waveProgress } from "../../story/wave_moved.ts";
1818 import { carriedPulls, pullPath } from "../../story/wave_pulls.ts";
1919 import { fromWave, pullRefs, readWave, readWaves } from "../../story/waves.ts";
2020 import { NO_WAVES } from "../../story/words.ts";
2121 import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts";
2222 import { escapeHtml, html } from "../html.ts";
2323 import { page } from "../layout.ts";
24 import { btn, empty, listCard, pill, row } from "../ui.ts";
24 import { btn, empty, listCard, row } from "../ui.ts";
2525 import { localScope } from "../scope.ts";
26 import { milestoneHref, planFor, progress } from "./milestones.ts";
26 import { milestoneHref, planFor } from "./milestones.ts";
2727
2828 /**
2929 * A wave's headline is the writer's words, so a row lets it wrap (a long word
@@ -204,7 +204,7 @@ export async function wavePage(ctx: Ctx): Promise<Response> {
204204 : [];
205205 const moved = entry.ok
206206 ? timePhase(ctx.request, "moved", () =>
207 movedForViewer({
207 movedWithPulls({
208208 db: ctx.app.db,
209209 repo,
210210 refs: pullRefs(entry.wave),
@@ -217,9 +217,25 @@ export async function wavePage(ctx: Ctx): Promise<Response> {
217217 const scope = localScope(ctx, repo);
218218 const back = html`<a href="${repoBase(repo)}/waves">All waves</a>`;
219219
220 const byNumber = new Map(carried.filter((p) => p.owner === repo.ownerName && p.name === repo.name).map((p) => [p.number, p]));
221 const pullRow = (p: (typeof carried)[number]) =>
222 row({
223 href: `${pullPath(p)}${fromWave(repo.ownerName, repo.name, entry.ok ? entry.wave.version : "")}`,
224 title: html`#${p.number} ${p.title}`,
225 meta: html`${p.mergedAt === null ? p.state : `merged ${p.mergedAt.slice(0, 10)}`}`,
226 state: p.mergedAt === null ? p.state : ("merged" as const),
227 });
228 /** Each pull is said once: those a change's fold holds are not listed again below. */
229 const folded = new Set(moved.flatMap((m) => m.pulls));
230 const unfolded = carried.filter((p) => !(p.owner === repo.ownerName && p.name === repo.name && folded.has(p.number)));
231 const line = waveProgress(moved);
232
220233 const content = entry.ok
221234 ? html`<h1>${entry.wave.headline}</h1>
222235 <p class="muted">${entry.wave.version} · ${entry.wave.date}</p>
outdated

Principle 1: the bar and the same text line say the same thing, and the Moved row repeats it again with a bar and a pill. Show the progress once (the line, or the bar with its label).

236 ${line === ""
237 ? ""
238 : html`<p class="muted" data-wave-progress>${line}</p>`}
223239 <h2>Highlights</h2>
224240 <ul>
225241 ${entry.wave.highlights.map(
@@ -229,27 +245,26 @@ export async function wavePage(ctx: Ctx): Promise<Response> {
229245 ${moved.length === 0
230246 ? ""
231247 : listCard(
232 moved.map((m) =>
233 row({
234 href: milestoneHref(repoBase(repo), m.number),
248 moved.map((m) => {
249 const its = m.pulls.flatMap((n) => byNumber.get(n) ?? []);
250 return row({
235251 title: m.title,
236 meta: progress(m.done, m.total, `${m.done} of ${m.total} done`),
237 trail: pill(`${m.done} of ${m.total} done`),
238 }),
239 ),
252 fold: {
253 id: `change-${m.number}`,
254 open: false,
255 body: html`<p class="small"><a href="${milestoneHref(repoBase(repo), m.number)}">Open the milestone</a></p>
256 ${its.length === 0 ? "" : html`<ul class="list">${its.map(pullRow)}</ul>`}`,
257 },
258 });
259 }),
240260 { head: html`<h2>Moved</h2>` },
241261 )}
242262 ${carried.length === 0
243263 ? html`<p class="muted">No pulls to show here.</p>`
244 : listCard(
245 carried.map((p) =>
246 row({
247 href: `${pullPath(p)}${fromWave(repo.ownerName, repo.name, entry.wave.version)}`,
248 title: html`#${p.number} ${p.title}`,
249 meta: html`${p.mergedAt === null ? p.state : `merged ${p.mergedAt.slice(0, 10)}`}`,
250 state: p.mergedAt === null ? p.state : ("merged" as const),
251 }),
252 ),
264 : unfolded.length === 0
outdated

Principle 1: these pulls are listed again in the Pulls list below. Say each once, or drop the full list when folds hold them.

265 ? ""
266 : listCard(
267 unfolded.map(pullRow),
253268 { head: html`<h2>Pulls</h2>` },
254269 )}`
255270 : html`<h1>${entry.file}</h1>
src/web/studio/cards.ts +1
@@ -176,6 +176,7 @@ export function cardFor(
176176 const repo = text(obj(value)["repo"]);
177177 const pulls = list(w["pulls"]).map(text);
178178 rows.push({ label: "Headline", value: text(w["headline"]) });
179 if (text(w["progress"]) !== "") rows.push({ label: "Progress", value: text(w["progress"]) });
179180 for (const h of list(w["highlights"]).map(obj).slice(0, LIMIT))
180181 rows.push({ label: text(h["title"]), value: text(h["why"]) });
181182 if (pulls.length > 0)
test/tui-waves.test.ts +3
@@ -229,6 +229,9 @@ describe("waves in the terminal", () => {
229229 owner.stop();
230230 expect(owner.text).toContain("Moved");
231231 expect(owner.text).toContain("Open goal - 1 of 2 done");
232 // the progress line under the date, and the pull under the milestone it moved
233 expect(owner.text).toContain("Open goal: 1 of 2 done");
234 expect(owner.text).toMatch(/milestones\/\d+\n\s+#\d+ Part A work/);
232235 expect(owner.text).toContain(`/alice/ms/milestones/${goal.number}`);
233236 expect(owner.text).not.toContain("Hidden goal");
234237 const outsider = await open(bob, repo, ["enter"]);
test/wave-folds.test.ts added +137
@@ -0,0 +1,137 @@
1 /** A wave opens as headline, then its progress, then its changes (one closed fold each, its pulls inside); the MCP card says the same. */
2 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { linkIssue } from "../src/actions/dependencies.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
9 import { createApp, type App } from "../src/app.ts";
10 import { getIssueById, setIssueState, type Issue } from "../src/core/issues.ts";
11 import { getRepoById, type Repo } from "../src/core/repos.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { cardFor } from "../src/web/studio/cards.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { mergedPull, waveNaming } from "./helpers/wave-pulls.ts";
18
19 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
20 if (!r.ok) throw new Error(JSON.stringify(r.error));
21 return r.value;
22 };
23
24 describe("wave folds", () => {
25 let dataDir: string;
26 let app: App;
27 let servers: Servers;
28 let alice: User;
29 let cookie: string;
30 let repo: Repo;
31 const fresh = () => getRepoById(app.db, repo.id)!;
32 const open = (title: string): Issue => must(openIssue(app, alice, fresh(), { title }));
33 const link = (w: Issue, on: Issue) =>
34 must(linkIssue(app, alice, fresh(), getIssueById(app.db, w.id)!, getIssueById(app.db, on.id)!));
35 const closing = async (title: string, closes: Issue): Promise<number> => {
36 const n = await mergedPull(app, alice, repo, title);
37 app.db.query("UPDATE issue SET body = ? WHERE repo_id = ? AND number = ?").run(`Closes #${closes.number}`, repo.id, n);
38 return n;
39 };
40 const publish = async (version: string, numbers: number[]): Promise<string> => {
41 must(
42 await commitToBranch(app, alice, fresh(), {
43 message: `wave ${version}`,
44 files: [{ path: `waves/${version}.md`, content: waveNaming(version, numbers.map((number) => ({ owner: "alice", name: "plan", number }))) }],
45 }),
46 );
47 return version;
48 };
49 const get = async (version: string) =>
50 (await fetch(`${servers.url}/alice/plan/waves/${version}`, { headers: { cookie } })).text();
51
52 beforeAll(async () => {
53 dataDir = await mkdtemp(join(tmpdir(), "forge-wave-folds-"));
54 app = createApp({ dataDir, port: 0 });
55 servers = await serve(app);
56 alice = must(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }));
57 cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=dev`;
58 repo = must(await createRepo(app, alice, { name: "plan", visibility: "public" }));
59 must(await commitToBranch(app, alice, repo, { message: "init", files: [{ path: "README.md", content: "hi\n" }] }));
60 }, 60_000);
61
62 afterAll(async () => {
63 servers.stop();
64 app.close();
65 await rm(dataDir, { recursive: true, force: true });
66 });
67
68 test("headline, then progress, then two closed change folds each holding its pull, then the pulls", async () => {
69 const m1 = open("Faster pages");
70 const m2 = open("Calmer inbox");
71 const a = open("Cache pages");
72 const b = open("Group mail");
73 const c = open("Spare part");
74 link(m1, a);
75 link(m1, c);
76 link(m2, b);
77 setIssueState(app.db, a.id, "closed", "done");
78 setIssueState(app.db, b.id, "closed", "done");
79 const p1 = await closing("Cache work", a);
80 const p2 = await closing("Mail work", b);
81 await publish("1.0.0", [p1, p2]);
82 const text = await get("1.0.0");
83 const at = (s: string) => {
84 const i = text.indexOf(s);
85 expect(i).toBeGreaterThan(-1);
86 return i;
87 };
88 const progress = at("Faster pages: 1 of 2 done");
89 expect(at("<h1>Mixed</h1>")).toBeLessThan(progress);
90 const fold1 = at('<details id="change-' + m1.number + '"');
91 const fold2 = at('<details id="change-' + m2.number + '"');
92 expect(progress).toBeLessThan(fold1);
93 expect(fold1).toBeLessThan(fold2);
94 // closed folds: no `open` attribute
95 expect(text.slice(fold1, fold1 + 40)).not.toContain("open");
96 // each fold holds only its own pull
97 const first = text.slice(fold1, fold2);
98 expect(first).toContain("Cache work");
99 expect(first).not.toContain("Mail work");
100 expect(text.slice(fold2)).toContain("Mail work");
101 // progress is said once, and each pull once (in its fold, not again under Pulls)
102 expect(text.split("1 of 2 done").length - 1).toBe(1);
103 expect(text).not.toContain("1 of 1 done");
104 expect(text.split("Cache work").length - 1).toBe(1);
105 expect(text.split("Mail work").length - 1).toBe(1);
106 });
107
108 test("a wave with no milestone has no progress line", async () => {
109 const loose = open("Unrelated");
110 await publish("1.1.0", [await closing("Loose work", loose)]);
111 const text = await get("1.1.0");
112 expect(text).not.toContain("data-wave-progress");
113 expect(text).not.toContain('<details id="change-');
114 });
115
116 const mcp = async (version: string) => {
117 const { token } = createToken(app.db, alice.id, "ro", "ro");
118 const res = await fetch(`${servers.url}/mcp`, {
119 method: "POST",
120 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
121 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "waves", arguments: { repo: "alice/plan", version } } }),
122 });
123 const body = (await res.json()) as any;
124 return { value: JSON.parse(body.result.content[0].text), card: body.result.structuredContent?.card };
125 };
126
127 test("the MCP wave says the same progress line as the page, and the card shows it", async () => {
128 const { value, card } = await mcp("1.0.0");
129 expect(value.wave.progress).toBe("Faster pages: 1 of 2 done");
130 expect(cardFor("waves", value)?.rows).toContainEqual({ label: "Progress", value: "Faster pages: 1 of 2 done" });
131 expect(card.rows).toContainEqual({ label: "Progress", value: "Faster pages: 1 of 2 done" });
132 expect(await get("1.0.0")).toContain(value.wave.progress);
133 const none = await mcp("1.1.0");
134 expect(none.value.wave.progress).toBeUndefined();
135 expect(none.card.rows.some((r: { label: string }) => r.label === "Progress")).toBe(false);
136 });
137 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs