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

A wave on /changelog says in plain numbers how many pulls it gathered and how many apps they touched #11351

Merged opened this

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

Merged

torque/forge:torque/11334-a-wave-on-changelog-says-in-plain-number → main 6 commits fork 3 threads · 3 unresolved

  • Merged. torque/11334-a-wave-on-changelog-says-in-plain-number was merged into main as 5ab15c5.

Review

Sign in to review this change.
10 files changed +111 −36
docs/features/wave-walk.md +4
@@ -22,3 +22,7 @@ When a wave's pulls closed issues under a milestone, the wave opens as its headl
2222 3. Esc (or `q`) on the pull returns to the wave, and again to the list.
2323
2424 Only pulls the viewer may read are listed, and Enter re-checks that before opening one. The walk is tested in `test/story-walk.test.ts` and `test/tui-waves.test.ts`.
25
26 ## A wave's size
27
28 Each wave says how big it is in plain numbers, such as `7 changes in 3 apps`: on the list row, under the headline on the wave's page, on the terminal's waves list and at the top of a wave there, and as `pulls` and `apps` on each item of `GET /api/v1/repos/<owner>/<repo>/waves`. The numbers count only the pulls you can read, once each, so a guest is not told how many private pulls a wave holds.
src/http/api.ts +12 −2
@@ -291,7 +291,7 @@ import { blobForRender, commitLog, listRefs, listTree, refTip } from "../git/rea
291291 import { repoDir } from "../git/repos.ts";
292292 import { holds, weakEtag } from "./etag.ts";
293293 import { carriedPulls, pullPath } from "../story/wave_pulls.ts";
294 import { pullRefs, readWave, readWaves } from "../story/waves.ts";
294 import { pullRefs, readWave, readWaves, waveSize } from "../story/waves.ts";
295295 import { originForRepo, type OriginRepoRow } from "../core/origins.ts";
296296 import { subscribe as subscribeJob, type MirrorJob } from "../origins/jobs.ts";
297297 import type { Origin } from "../origins/types.ts";
@@ -2240,10 +2240,20 @@ export function apiRoutes(router: Router<Handler>): void {
22402240 const entries = await timePhaseAsync(ctx.request, "waves", () =>
22412241 readWaves(repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch),
22422242 );
2243 // Sized over the pulls this viewer can read, as the page does.
22432244 return timePhase(ctx.request, "render", () =>
22442245 json({
22452246 items: entries.flatMap((e) =>
2246 e.ok ? [{ version: e.wave.version, date: e.wave.date, headline: e.wave.headline }] : [],
2247 e.ok
2248 ? [
2249 {
2250 version: e.wave.version,
2251 date: e.wave.date,
2252 headline: e.wave.headline,
2253 ...waveSize(carriedPulls(ctx.app.db, pullRefs(e.wave), ctx.auth.user)),
2254 },
2255 ]
2256 : [],
22472257 ),
22482258 next_cursor: null,
22492259 }),
src/story/waves.ts +19
@@ -164,6 +164,25 @@ export function pullRefs(wave: Wave): PullRef[] {
164164 return out;
165165 }
166166
167 /**
outdated

waveSize takes the whole wave, so it cannot honour visibility. Take the visible refs instead, and the API must filter the same way for the viewer.

168 * How big a wave is for one viewer: the pulls they can see, once each, and the
169 * distinct repositories (apps) those sit in. Takes the visible pulls (what
170 * `carriedPulls` returns), never the whole wave, so a hidden pull is not counted.
171 */
172 export function waveSize(refs: readonly { owner: string; name: string }[]): { pulls: number; apps: number } {
173 return { pulls: refs.length, apps: new Set(refs.map((r) => `${r.owner}/${r.name}`)).size };
174 }
175
176 /** `7 changes in 3 apps`; `1 change in 1 app`. Numbers only, so nothing here needs escaping. */
177 export function sizeWords(size: { pulls: number; apps: number }): string {
178 return `${size.pulls} ${size.pulls === 1 ? "change" : "changes"} in ${size.apps} ${size.apps === 1 ? "app" : "apps"}`;
179 }
180
181 /** A wave's one-line title, on the terminal's list: `<version> · <date> · <headline> · N changes in M apps`, counted over what this viewer can see. */
182 export function waveLine(w: Wave, size: { pulls: number; apps: number }): string {
183 return `${w.version} · ${w.date} · ${w.headline} · ${sizeWords(size)}`;
184 }
185
167186 /** Unreadable files tried before giving up on finding the newest good wave. */
168187 const LATEST_TRIES = 5;
169188
src/tui/screens/waves.ts +12 −6
@@ -12,7 +12,7 @@ import { movedWithPulls, waveProgress, type MovedMilestone } from "../../story/w
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";
15 import { pullRefs, readWave, readWaves, type WaveEntry } from "../../story/waves.ts";
15 import { pullRefs, readWave, readWaves, sizeWords, waveLine, waveSize, type WaveEntry } from "../../story/waves.ts";
1616 import { NO_WAVES } from "../../story/words.ts";
1717 import type { RepoSource } from "../../view/source.ts";
1818 import type { Ctx, Screen } from "../app/app.ts";
@@ -26,12 +26,12 @@ import { ListState, Scroll, confirmScreen, drawList, drawLines, hitList, plural,
2626 import { pullScreen } from "./pull.ts";
2727 import { local, localRepo, sourceName, whereRows, whereStrip } from "./where.ts";
2828
29 /** One line per wave, as the screen draws it. */
30 export function waveRows(entries: WaveEntry[]) {
29 /** One line per wave, as the screen draws it, sized as the page sizes it: `sizeOf` counts the pulls this viewer can see. */
30 export function waveRows(entries: WaveEntry[], sizeOf: (e: WaveEntry & { ok: true }) => { pulls: number; apps: number }) {
3131 return entries.map((e) =>
3232 e.ok
3333 ? {
34 title: `${e.wave.version} · ${e.wave.date} · ${e.wave.headline}`,
34 title: plain(waveLine(e.wave, sizeOf(e))),
3535 secondary: "",
3636 meta: "",
3737 }
@@ -59,7 +59,8 @@ export function waveLines(
5959 const w = entry.wave;
6060 return [
6161 plain(w.headline),
62 `${plain(w.version)} ${plain(w.date)}`,
62 // Sized over `pulls`, the ones this viewer may see, as the page's header is.
63 `${plain(w.version)} ${plain(w.date)} ${sizeWords(waveSize(pulls))}`,
6364 ...(moved.length === 0 ? [] : [plain(waveProgress(moved))]),
6465 "",
6566 "Highlights",
@@ -157,6 +158,8 @@ function waveScreen(
157158 export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
158159 const repo = localRepo(src);
159160 let entries: WaveEntry[] = [];
161 /** Each wave's size by file, counted once per load with the page's gate on each pull. */
162 let sizes = new Map<string, { pulls: number; apps: number }>();
160163 let loaded = false;
161164 const state = new ListState();
162165
@@ -194,6 +197,9 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
194197 repo !== undefined && canRead(f.db, repo, f.user)
195198 ? await readWaves(f.dir(repo), repo.defaultBranch)
196199 : [];
200 sizes = new Map(
201 entries.flatMap((e) => (e.ok ? [[e.file, waveSize(carriedPulls(f.db, pullRefs(e.wave), f.user))] as const] : [])),
202 );
197203 loaded = true;
198204 ctx.invalidate();
199205 };
@@ -279,7 +285,7 @@ export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen {
279285 });
280286 return;
281287 }
282 const rows = waveRows(entries);
288 const rows = waveRows(entries, (e) => sizes.get(e.file) ?? { pulls: 0, apps: 0 });
283289 drawList(s, body!, c, state, rows);
284290 hitList(ctx, body!, state, rows.length, { open: openAt });
285291 },
src/web/pages/waves.ts +5 −10
@@ -16,7 +16,7 @@ import type { Ctx } from "../../http/context.ts";
1616 import { VERSION, type Wave } from "../../story/wave.ts";
1717 import { movedWithPulls, waveProgress } from "../../story/wave_moved.ts";
1818 import { carriedPulls, pullPath } from "../../story/wave_pulls.ts";
19 import { fromWave, pullRefs, readWave, readWaves } from "../../story/waves.ts";
19 import { fromWave, pullRefs, readWave, readWaves, sizeWords, waveSize } 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";
@@ -37,20 +37,15 @@ export const WAVES_CSS = `[data-waves] { overflow-wrap: anywhere; }
3737 const WHAT_A_WAVE =
3838 "A wave is a release note in waves/<version>.md that names its highlights and the pulls merged since the last one.";
3939
40 /** A wave's row title: `<version> · <date> · <headline> · N pulls`, N being the pulls this viewer can see. */
41 export function waveLine(w: Wave, visible: number): string {
42 return `${w.version} · ${w.date} · ${w.headline} · ${visible} ${visible === 1 ? "pull" : "pulls"}`;
43 }
44
4540 /** One wave on the list: its headline, the first highlight's why as the one sentence under it, and version, date and pull count as a muted trail. */
46 export function waveRow(w: Wave, href: string, visible: number) {
41 export function waveRow(w: Wave, href: string, size: { pulls: number; apps: number }) {
4742 const why = w.highlights[0]?.why;
4843 return row({
4944 href,
5045 nav: true,
5146 title: w.headline,
5247 ...(why === undefined || why === "" ? {} : { meta: html`${why}` }),
53 trail: html`<span class="muted small">${w.version} · ${w.date} · ${visible} ${visible === 1 ? "pull" : "pulls"}</span>`,
48 trail: html`<span class="muted small">${w.version} · ${w.date} · <span data-wave-size>${sizeWords(size)}</span></span>`,
5449 });
5550 }
5651
@@ -76,7 +71,7 @@ export async function wavesPage(ctx: Ctx): Promise<Response> {
7671 e.wave,
7772 fileHref(e.file),
7873 // The wave's own page lists these same pulls, so the two agree.
79 carriedPulls(ctx.app.db, pullRefs(e.wave), ctx.auth.user).length,
74 waveSize(carriedPulls(ctx.app.db, pullRefs(e.wave), ctx.auth.user)),
8075 )
8176 : row({
8277 href: fileHref(e.file),
@@ -232,7 +227,7 @@ export async function wavePage(ctx: Ctx): Promise<Response> {
232227
233228 const content = entry.ok
234229 ? html`<h1>${entry.wave.headline}</h1>
235 <p class="muted">${entry.wave.version} · ${entry.wave.date}</p>
230 <p class="muted">${entry.wave.version} · ${entry.wave.date} · <span data-wave-size>${sizeWords(waveSize(carried))}</span></p>
236231 ${line === ""
237232 ? ""
238233 : html`<p class="muted" data-wave-progress>${line}</p>`}
test/api-waves.test.ts +9 −2
@@ -123,8 +123,8 @@ test("the list is newest first by version, not by name", async () => {
123123 expect(got.status).toBe(200);
124124 expect(JSON.parse(got.body)).toEqual({
125125 items: [
126 { version: "0.10.0", date: "2026-02-01", headline: "Tenth" },
127 { version: "0.9.0", date: "2026-01-01", headline: "Ninth" },
126 { version: "0.10.0", date: "2026-02-01", headline: "Tenth", pulls: 0, apps: 0 },
127 { version: "0.9.0", date: "2026-01-01", headline: "Ninth", pulls: 0, apps: 0 },
128128 ],
129129 next_cursor: null,
130130 });
@@ -151,6 +151,13 @@ test("a wave's pulls are the page's: the owner sees the private one, an outsider
151151 expect(outsider.body).not.toContain("/alice/hid/pulls/");
152152 });
153153
154 test("the list sizes a wave over the pulls the viewer can see, so hidden ones and apps are not counted", async () => {
155 const sizeOf = async (cookie?: string) =>
156 JSON.parse((await get("/api/v1/repos/alice/mix/waves", cookie)).body).items.find((i: { version: string }) => i.version === "0.1.0");
157 expect(await sizeOf()).toMatchObject({ pulls: 2, apps: 2 });
158 expect(await sizeOf(bobCookie)).toMatchObject({ pulls: 1, apps: 1 });
159 });
160
154161 test("an unknown or malformed version is 404", async () => {
155162 for (const v of ["9.9.9", "nope", "1.2", "..%2Fx"])
156163 expect((await get(`/api/v1/repos/alice/pub/waves/${v}`)).status).toBe(404);
test/looks-owner.test.ts +1 −1
@@ -341,7 +341,7 @@ describe("the forge's own owner looks, over the preview seed", () => {
341341 const waves = await asOwner(`${repoHome}/waves`);
342342 expect(waves).toContain(PREVIEW_WAVE);
343343 expect(waves).toContain(PREVIEW_WAVE_HEADLINE);
344 expect(waves).toContain("1 pull");
344 expect(waves).toContain("1 change in 1 app");
345345 const milestones = await asOwner(`${repoHome}/milestones`);
346346 expect(milestones).toContain("The home page says hello");
347347 expect(milestones).toContain("2 of 3 done");
test/tui-waves.test.ts +18 −1
@@ -170,7 +170,7 @@ describe("waves in the terminal", () => {
170170 const h = await open(alice, pub, ["enter"]);
171171 h.stop();
172172 expect(h.text).toContain("Tenth");
173 expect(h.text).toContain("0.10.0 2026-02-01");
173 expect(h.text).toContain("0.10.0 2026-02-01 0 changes in 0 apps");
174174 expect(h.text).toContain("Thing 1 - because");
175175 expect(h.text).toContain("No pulls to show here.");
176176 });
@@ -188,6 +188,23 @@ describe("waves in the terminal", () => {
188188 expect(outsider.text).not.toContain("/alice/priv/pulls/");
189189 });
190190
191 test("a wave's size counts only the pulls the viewer can see, on the list row and the wave's header", async () => {
192 const ownerList = await open(alice, mix);
193 ownerList.stop();
194 expect(ownerList.text).toContain("0.1.0 · 2026-03-05 · Mixed · 2 changes in 2 apps");
195 const owner = await open(alice, mix, ["enter"]);
196 owner.stop();
197 expect(owner.text).toContain("0.1.0 2026-03-05 2 changes in 2 apps");
198 const outsider = await open(bob, mix, ["enter"]);
199 outsider.stop();
200 expect(outsider.text).toContain("1 change in 1 app");
201 expect(outsider.text).not.toContain("2 changes");
202 // The list row, before Enter, says the same.
203 const list = await open(bob, mix);
204 list.stop();
205 expect(list.text).toContain("0.1.0 · 2026-03-05 · Mixed · 1 change in 1 app");
206 });
207
191208 test("the wave leads to its pulls and back: pick, open the pull, back returns to the wave", async () => {
192209 const h = await open(alice, mix, ["enter", "n", "enter", "q"]);
193210 h.stop();
test/waves-line-date.test.ts +8 −7
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { waveLine } from "../src/web/pages/waves.ts";
2 import { waveLine } from "../src/story/waves.ts";
33 import { waveRows } from "../src/tui/screens/waves.ts";
44 import type { Wave } from "../src/story/wave.ts";
55
@@ -12,13 +12,14 @@ const wave = (version: string, date: string, headline: string): Wave => ({
1212 });
1313
1414 test("waveLine puts the date right after the version", () => {
15 expect(waveLine(wave("0.7.12", "2026-10-20", "Headline"), 4)).toBe(
16 "0.7.12 · 2026-10-20 · Headline · 4 pulls",
15 expect(waveLine(wave("0.7.12", "2026-10-20", "Headline"), { pulls: 4, apps: 2 })).toBe(
16 "0.7.12 · 2026-10-20 · Headline · 4 changes in 2 apps",
1717 );
18 expect(waveLine(wave("0.7.12", "2026-10-20", "Headline"), 1)).toContain("1 pull");
18 expect(waveLine(wave("0.7.12", "2026-10-20", "Headline"), { pulls: 1, apps: 1 })).toContain("1 change in 1 app");
1919 });
2020
21 test("the terminal's row says the same line", () => {
22 const rows = waveRows([{ ok: true, file: "0.7.12.md", wave: wave("0.7.12", "2026-10-20", "Headline") }]);
23 expect(rows[0]!.title).toBe("0.7.12 · 2026-10-20 · Headline");
21 test("the terminal's row says the same line, size and all", () => {
22 const entry = { ok: true as const, file: "0.7.12.md", wave: wave("0.7.12", "2026-10-20", "Headline") };
23 expect(waveRows([entry], () => ({ pulls: 7, apps: 3 }))[0]!.title).toBe("0.7.12 · 2026-10-20 · Headline · 7 changes in 3 apps");
24 expect(waveRows([entry], () => ({ pulls: 1, apps: 1 }))[0]!.title).toBe("0.7.12 · 2026-10-20 · Headline · 1 change in 1 app");
2425 });
test/waves-page.test.ts +23 −7
@@ -11,6 +11,7 @@ import { serve, type Servers } from "../src/http/server.ts";
1111 import { mergedPull, waveNaming } from "./helpers/wave-pulls.ts";
1212 import { waveRow } from "../src/web/pages/waves.ts";
1313 import { writeWave, type Wave } from "../src/story/wave.ts";
14 import { pullRefs, sizeWords, waveSize } from "../src/story/waves.ts";
1415
1516 const PASSWORD = "a-good-passphrase";
1617
@@ -106,8 +107,8 @@ describe("waves page", () => {
106107 expect(at("Tenth")).toBeGreaterThan(-1);
107108 expect(at("Tenth")).toBeLessThan(at("Ninth"));
108109 expect(at("Ninth")).toBeLessThan(at("Zeta"));
109 expect(body).toContain("0.10.0 · 2026-02-01 · 0 pulls");
110 expect(body).toContain("0.9.0 · 2026-01-01 · 0 pulls");
110 expect(body).toContain("0.10.0 · 2026-02-01 · <span data-wave-size>0 changes in 0 apps<");
111 expect(body).toContain("0.9.0 · 2026-01-01 · <span data-wave-size>0 changes in 0 apps<");
111112 expect(at("0.10.0 · 2026-02-01")).toBeLessThan(at("0.9.0 · 2026-01-01"));
112113 expect(body).toContain('href="/alice/pub/waves/0.10.0"');
113114 });
@@ -227,13 +228,15 @@ describe("waves page", () => {
227228
228229 test("a row counts the pulls this viewer can see, as the wave's page lists them", async () => {
229230 const body = await (await get("/alice/pub/waves")).text();
230 expect(body).toContain("0.1.0 · 2026-03-05 · 2 pulls");
231 expect(body).toContain("0.1.0 · 2026-03-05 · <span data-wave-size>2 changes in 2 apps<");
231232 const guest = await (await get("/alice/pub/waves", false)).text();
232 expect(guest).toContain("0.1.0 · 2026-03-05 · 1 pull<");
233 expect(guest).not.toContain("2 pulls");
233 expect(guest).toContain("0.1.0 · 2026-03-05 · <span data-wave-size>1 change in 1 app<");
234 expect(guest).not.toContain("2 changes");
235 expect(guest).not.toMatch(/\d+ pulls?</);
234236 expect(guest).not.toContain("Secret work");
235237 const page = await (await get("/alice/pub/waves/0.1.0", false)).text();
236238 expect(page).toContain("Shown work");
239 expect(page).toContain("data-wave-size>1 change in 1 app<");
237240 expect(page).not.toContain("Secret work");
238241 });
239242
@@ -248,10 +251,10 @@ describe("waves page", () => {
248251
249252 test("a wave whose first why is empty has a row with no sentence and no stray separator", () => {
250253 const w = { ...wave("0.9.0", "2026-01-01", "Quiet"), highlights: [{ title: "T", why: "" }] };
251 const r = waveRow(w, "/x", 1).text;
254 const r = waveRow(w, "/x", { pulls: 1, apps: 1 }).text;
252255 expect(r).toContain("Quiet");
253256 expect(r).not.toContain("row-meta");
254 expect(r).toContain("0.9.0 · 2026-01-01 · 1 pull");
257 expect(r).toContain("0.9.0 · 2026-01-01 · <span data-wave-size>1 change in 1 app");
255258 expect(r).not.toMatch(/·\s*·/);
256259 });
257260
@@ -287,3 +290,16 @@ describe("waves page", () => {
287290 expect(body).toContain("application/atom+xml");
288291 });
289292 });
293
294 describe("a wave says its size in plain numbers", () => {
295 const url = (n: number, repo = "r") => ({ title: `#${n} P`, url: `https://forge.example/o/${repo}/pulls/${n}` });
296 test("plural, singular, apps counted once each, a repeated pull once", () => {
297 const w = { ...wave("1.0.0", "2026-01-01", "Big"), pulls: [url(1), url(2), url(2), url(3, "s"), url(4, "t")] };
298 expect(sizeWords(waveSize(pullRefs(w)))).toBe("4 changes in 3 apps");
299 const one = { ...wave("1.0.1", "2026-01-01", "Small"), pulls: [url(1)] };
300 expect(waveSize(pullRefs(one))).toEqual({ pulls: 1, apps: 1 });
301 expect(sizeWords(waveSize(pullRefs(one)))).toBe("1 change in 1 app");
302 expect(waveRow(w, "/x", waveSize(pullRefs(w))).text).toContain("4 changes in 3 apps");
303 expect(waveRow(one, "/x", waveSize(pullRefs(one))).text).toContain("1 change in 1 app");
304 });
305 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs