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

/changelog groups releases under the chapter they closed #11896

Merged opened this

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

Merged

torque/forge:torque/11868-changelog-groups-releases-under-the-chap → main 3 commits fork 2 threads · 2 unresolved

  • Merged. torque/11868-changelog-groups-releases-under-the-chap was merged into main as cdc2aed.

Review

Sign in to review this change.
7 files changed +357 −11
docs/design/areas/changelog.md +4
@@ -16,3 +16,7 @@ Each day is a `<section data-day>` with an `<h2>` (the day, the UTC mark when th
1616 ## Releases as rows (#9753)
1717
1818 `/releases` is one `list setting-list` of `row()`s, no table, no card. The title is the short sha with its `live` and `preflight failed` badges; the meta line is the pull (`#7`), when it landed and, for the live one, since when it has been healthy. A release with a pull is one `<a>` to that pull; the Roll back form sits in the row's `after` slot, outside the link, so it posts with JavaScript off. The empty state is one muted line. No new class; `test/releases-page.test.ts` holds the shape.
19
20 ## Chapters on the changelog (#11868)
21
22 A closed chapter is one `row()` in a `list setting-list` (its name as the title, the closing note's first sentence in plain words as the meta, `Means` and `Pulls` as the row's `facts` dl, the close date as the trail), followed by the pulls under the same day headings as everywhere else. The closing note is never shown as raw markdown: `noteWords` drops the headings and marks and keeps one sentence. No new class; `test/changelog-chapters.test.ts` holds it.
docs/features/changelog.md +6
@@ -33,3 +33,9 @@ A squash keeps the people behind its commits: the landed message carries every d
3333 `GET /changelog.atom` is the same list as an Atom feed, public, for a feed reader. It holds the newest 50 live landings of the forge's own repository (`FORGE_REPO`, public only), one entry each titled `Went live: <title>` and linked to its pull; what has landed but is not yet live is left out. A landing a wave covers collapses into one entry per wave (not per release or per landing): the wave's headline is the title, its highlights the summary, the link goes to the wave's page, and the entry is dated at the wave's newest landing. Waves are read from the forge repository's default branch, so the `ETag` moves with that branch's tip as well as with the list. The tip is read on every request, the 304 path included; the five-minute cache is what keeps git off the hot path. The 50 is the feed's own cap (`CHANGELOG_ENTRIES` in `web/feeds.ts`); the page and `/api/v1/changelog` are not capped. It is cached for five minutes (`cache-control: public, max-age=300`) and carries a weak `ETag`, so a reader that sends `If-None-Match` gets a 304. The page links it with `rel="alternate"`.
3434
3535 An agent reads the same data as JSON, not Atom: MCP `releases` with `{shipped: true}` or `GET /api/v1/changelog` (see above). The terminal has no screen for the feed, since a feed is for a reader program; its Changelog screen is the release-commit view.
36
37 ## Chapters (#11868)
38
39 A pull that landed while a chapter was open, and that chapter has since closed (tagged, `closedAt` passed), is drawn under that chapter on `/changelog`: one row (the chapter's name linked to its page, the first sentence of its closing note in plain words, "Means" and "Pulls" as facts, the close date), then its pulls under the usual day headings, so every pull keeps its date. Pulls outside any closed chapter stay flat in runs, in order; a chapter with no pull in the list, one still open, and one in a repository the reader may not read are not drawn. The terminal's Changelog screen groups the same way: a `Chapter v1` line (release count and the note's first sentence) over the releases that fell in its window, indented. Both read `release/chapter_words.ts`.
40
41 Membership is by time, not by recorded tag: a landing belongs to the chapter open when it merged (opened < merged ≤ closed). A landing at the edge of a window can be placed in a neighbouring chapter; the chapter's own page, which reads its issues, is the exact record. The page's `ETag` holds one aggregate query over closed chapters (`chapterStamp`: count, newest close, repository visibility); the per-repository chapter reads run only after the tag has missed, so a 304 costs one query. The browser's day regrouping (`localDaysJs`) leaves a page with chapters alone.
src/release/chapter_words.ts added +48
@@ -0,0 +1,48 @@
1 /**
2 * What the changelog says about a closed chapter, shared by the page and the
3 * terminal so they cannot disagree (#11868): which moments belong to it, and
4 * its closing note in plain words.
5 *
6 * Membership is by time, not by tag: a pull or release belongs to the chapter
7 * that was open when it landed (opened < at <= closed, the tag standing at the
8 * default branch's tip at the close). A landing at the very edge of a window
9 * can be placed in the neighbouring chapter; the chapter's own page, which
10 * reads its issues, is the exact record.
11 */
12
13 import type { Chapter } from "../core/chapters.ts";
14
15 /** A chapter that may head a group: closed, tagged and closed no later than `now`. */
16 export function isDrawable(c: Chapter, now: Date): boolean {
17 return c.state === "closed" && c.tagOid !== null && c.closedAt !== null && Date.parse(c.closedAt) <= now.getTime();
18 }
19
20 /** Whether `at` (an ISO time) falls inside the chapter's window. */
21 export function inChapter(c: Chapter, at: string): boolean {
22 const t = Date.parse(at);
23 return c.closedAt !== null && Date.parse(c.openedAt ?? c.createdAt) < t && t <= Date.parse(c.closedAt);
24 }
25
26 /** Longest the first sentence of a note is shown. */
27 const MOST = 200;
28
29 /**
30 * The first sentence of a closing note as plain words: markdown headings, emphasis,
31 * code marks, list bullets and link syntax are dropped (a link keeps its text).
32 * Empty when the note says nothing in words.
33 */
34 export function noteWords(notes: string): string {
35 const body = notes
36 .split("\n")
37 .filter((line) => !/^\s*#{1,6}\s/.test(line) && !/^\s*(```|~~~)/.test(line))
38 .map((line) => line.replace(/^\s*(?:[-*+]|\d+\.)\s+/, "").replace(/^\s*>\s?/, ""))
39 .join(" ")
40 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
41 .replace(/[`*_~]/g, "")
42 .replace(/<[^>]*>/g, "")
43 .replace(/\s+/g, " ")
44 .trim();
45 const stop = body.search(/[.!?](\s|$)/);
46 const sentence = stop === -1 ? body : body.slice(0, stop + 1);
47 return sentence.length > MOST ? `${sentence.slice(0, MOST - 1).trimEnd()}…` : sentence;
48 }
src/tui/screens/changelog.ts +41 −5
@@ -5,6 +5,8 @@
55 * pull title; Enter lists a release's pulls, and Enter on a pull opens it.
66 */
77
8 import { listChapters, type Chapter } from "../../core/chapters.ts";
9 import { inChapter, isDrawable, noteWords } from "../../release/chapter_words.ts";
810 import { gather, plainTitle } from "../../story/gather.ts";
911 import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts";
1012 import { readLandings } from "../../release/landings.ts";
@@ -36,6 +38,36 @@ export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] {
3638 }));
3739 }
3840
41 /** One line of the list: a release, or the chapter heading over the releases it closed. */
42 export interface Line {
43 readonly row: ListRow;
44 readonly entry?: ReleaseEntry;
45 }
46
47 /**
48 * The releases (newest first) as lines, each run that fell in a closed chapter under a heading line
49 * with the chapter's name, its closing note's first sentence in plain words and its count, as the
50 * page groups them (`release/chapter_words.ts` decides membership). Releases outside any chapter
51 * stay flat. A chapter with none of the shown releases draws no heading.
52 */
53 export function changelogLines(entries: readonly ReleaseEntry[], chapters: readonly Chapter[], now: Date = new Date()): Line[] {
54 const usable = chapters.filter((c) => isDrawable(c, now));
55 const out: Line[] = [];
56 let at: Chapter | undefined;
57 for (const e of entries) {
58 const c = usable.find((u) => inChapter(u, e.date));
59 if (c !== undefined && c !== at) {
60 const n = entries.filter((x) => inChapter(c, x.date)).length;
61 const said = noteWords(c.notes);
62 out.push({ row: { title: `Chapter ${c.name}`, meta: metaOf(`${n} ${n === 1 ? "release" : "releases"}`, said) } });
63 }
64 at = c;
65 const [row] = changelogRows([e]);
66 out.push({ row: c === undefined ? row! : { ...row!, title: ` ${row!.title}` }, entry: e });
67 }
68 return out;
69 }
70
3971 /** The open milestones as lines above the releases: "name N of M done", at most `MOST` of them. */
4072 const MOST = 5;
4173 export function nearLines(near: readonly Near[]): string[] {
@@ -97,6 +129,8 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen {
97129 let repoRef: Repo | undefined;
98130 let credits = new Map<number, string>();
99131 let near: string[] = [];
132 let chapters: Chapter[] = [];
133 const lines = (): Line[] => changelogLines(shown(), chapters);
100134 const shown = (): ReleaseEntry[] => entries.filter((e) => releaseMatches(filter, e));
101135 return {
102136 title: "Changelog",
@@ -119,6 +153,7 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen {
119153 if (words !== "" && l.repo.toLowerCase() === repoName) credits.set(l.number, words);
120154 }
121155 }
156 chapters = read === undefined ? [] : listChapters(f.app.db, read.repo.id);
122157 near = nearLines(nearMilestones(f.app, f.app.config.forgeRepo, f.user)?.near ?? []);
123158 state.clamp(entries.length);
124159 loaded = true;
@@ -143,21 +178,22 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen {
143178 emptyState(s, body!, c, { title: "No release matches", body: "Esc clears the word." });
144179 return;
145180 }
146 drawList(s, body!, c, state, changelogRows(list));
181 const shownLines = lines();
182 drawList(s, body!, c, state, shownLines.map((l) => l.row));
147183 },
148184 key(e) {
149185 if (filter.key(e)) {
150 state.clamp(shown().length);
186 state.clamp(lines().length);
151187 return true;
152188 }
153189 if (e.key === "esc" && filter.query !== "") {
154190 filter.query = "";
155 state.clamp(shown().length);
191 state.clamp(lines().length);
156192 } else if (e.key === "esc" || e.key === "q") ctx.pop();
157193 else if (e.key === "enter") {
158 const entry = shown()[state.index];
194 const entry = lines()[state.index]?.entry;
159195 if (entry !== undefined && repoRef !== undefined) ctx.push(releasePulls(f, ctx, repoRef, entry, credits));
160 } else state.key(e, shown().length);
196 } else state.key(e, lines().length);
161197 return true;
162198 },
163199 };
src/web/pages/changelog.ts +118 −5
@@ -11,6 +11,10 @@ import type { Ctx } from "../../http/context.ts";
1111 import { betNamesOfPulls } from "../../core/bets.ts";
1212 import { sourcesOfPulls } from "../../core/trusted_sources.ts";
1313 import { canRead, getRepo, type Repo } from "../../core/repos.ts";
14 import { listChapters, type Chapter } from "../../core/chapters.ts";
15 import { inChapter, isDrawable, noteWords } from "../../release/chapter_words.ts";
16 import type { Database } from "bun:sqlite";
17 import type { User } from "../../core/users.ts";
1418 import { newestLive, secondsWords } from "../../keeper/landed.ts";
1519 import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts";
1620 import { chosenRepo, readLandings, type Landing, type Landings } from "../../release/landings.ts";
@@ -71,6 +75,104 @@ function gatheredRow(g: Gathered<Landing>, own: string | undefined, showRepo: bo
7175 });
7276 }
7377
78 /** A repository's chapter, with whether the reader may see that repository. */
79 export interface ChapterEntry {
80 readonly repo: string;
81 readonly chapter: Chapter;
82 readonly canSee: boolean;
83 }
84
85 export type Group =
86 | { readonly kind: "chapter"; readonly repo: string; readonly chapter: Chapter; readonly landings: readonly Landing[] }
87 | { readonly kind: "loose"; readonly landings: readonly Landing[] };
88
89 /**
90 * Landings (newest first) grouped under the chapter they closed (#11868). A landing belongs to a
91 * closed chapter of its repository when it merged after the chapter opened and no later than it
92 * closed (the tag stands at the default branch's tip at that close). Everything else is loose, in
93 * runs. A chapter is placed at its newest landing; one with no landing is not drawn; one the
94 * reader may not see, or that closes after `now`, is ignored. The words are the chapter's own
95 * (`chapter_notes.ts` wrote them at close); nothing is recomputed here.
96 */
97 export function groupLandings(landings: readonly Landing[], entries: readonly ChapterEntry[], now: Date): Group[] {
98 const usable = entries.filter((e) => e.canSee && isDrawable(e.chapter, now));
99 const closes = (e: ChapterEntry): number => Date.parse(e.chapter.closedAt!);
100 const owner = (l: Landing): ChapterEntry | undefined => {
101 return usable
102 .filter((e) => e.repo.toLowerCase() === l.repo.toLowerCase() && inChapter(e.chapter, l.at))
103 .sort((a, b) => closes(a) - closes(b))[0];
104 };
105 const out: Group[] = [];
106 const placed = new Map<ChapterEntry, Landing[]>();
107 let run: Landing[] | undefined;
108 for (const l of landings) {
109 const e = owner(l);
110 if (e === undefined) {
111 if (run === undefined) {
112 run = [];
113 out.push({ kind: "loose", landings: run });
114 }
115 run.push(l);
116 continue;
117 }
118 run = undefined;
119 const members = placed.get(e);
120 if (members !== undefined) members.push(l);
121 else {
122 const first = [l];
123 placed.set(e, first);
124 out.push({ kind: "chapter", repo: e.repo, chapter: e.chapter, landings: first });
125 }
126 }
127 return out;
128 }
129
130 /**
131 * One query that moves whenever a chapter closes, is reopened or deleted, or a repository with
132 * one changes visibility: what the page's tag holds in place of the chapters themselves.
133 */
134 export function chapterStamp(db: Database): string {
135 const r = db
136 .query<{ n: number; last: string | null; seen: string | null }, []>(
137 `SELECT count(*) AS n, max(chapter.closed_at) AS last, group_concat(DISTINCT repo.id || ':' || repo.visibility) AS seen
138 FROM chapter JOIN repo ON repo.id = chapter.repo_id WHERE chapter.state = 'closed'`,
139 )
140 .get();
141 return `${r?.n ?? 0}|${r?.last ?? ""}|${r?.seen ?? ""}`;
142 }
143
outdated

Query per repository on each request, before the 304 check; cache or batch.

144 /** The closed chapters of the repositories the live landings came from, each marked with whether `user` may read its repository. */
145 export function chapterEntries(db: Database, live: readonly Landing[], user: User | undefined): ChapterEntry[] {
146 const out: ChapterEntry[] = [];
147 for (const name of new Set(live.map((l) => l.repo))) {
outdated

Principles 6/7/8: hand-rolled section with a count badge and raw markdown in <pre>. Use named rows and plain words; no new unnamed markup.

148 const [o = "", n = ""] = name.split("/");
149 const repo = getRepo(db, o, n);
150 if (repo === undefined) continue;
151 const canSee = canRead(db, repo, user);
152 for (const chapter of listChapters(db, repo.id)) if (chapter.state === "closed") out.push({ repo: name, chapter, canSee });
153 }
154 return out;
155 }
156
157 /**
158 * A chapter's releases: the chapter is one row (its name linked to its page, the first sentence of
159 * its closing note in plain words, what it means and how many pulls it carried as facts), then the
160 * pulls under day headings as everywhere else, so each keeps its date.
161 */
162 function chapterSection(g: Extract<Group, { kind: "chapter" }>, own: string | undefined, showRepo: boolean, zone: string, today: string): Html {
163 const c = g.chapter;
164 const said = noteWords(c.notes);
165 const head = row({
166 href: `/${g.repo}/chapters/${c.name}`,
167 nav: true,
168 title: c.name,
169 ...(said === "" ? {} : { meta: html`${said}` }),
170 facts: [...(c.means === "" ? [] : [{ label: "Means", value: c.means }]), { label: "Pulls", value: String(g.landings.length) }],
171 ...(c.closedAt === null ? {} : { trail: time(c.closedAt) }),
172 });
173 return html`<section class="mt" data-chapter="${c.name}"><ul class="list setting-list">${head}</ul>${byDay(g.landings, own, showRepo, zone, today)}</section>`;
174 }
175
74176 /** Day sections in `zone`; the browser regroups them in its own (see `localDaysJs`), so the zone is named while it has not. */
75177 function byDay(list: readonly Landing[], own: string | undefined, showRepo: boolean, zone: string, today: string): Html[] {
76178 const days = new Map<string, Landing[]>();
@@ -91,6 +193,8 @@ export const localDaysJs = /* js */ `
91193 function regroup() {
92194 var root = document.getElementById('shipped');
93195 if (!root) return;
196 // Under chapter headings the server's grouping stands: moving rows between days would break the order.
197 if (root.querySelector('[data-chapter]')) return;
94198 var cards = root.querySelectorAll('[data-day]');
95199 var top = document.getElementById('shipped-today');
96200 if (!cards.length && !top) return;
@@ -156,11 +260,12 @@ export function shippedRegion(
156260 zone = "UTC",
157261 toLive?: { version: string; seconds: number },
158262 readable = true,
263 chapters: readonly ChapterEntry[] = [],
159264 ): Html {
160 const key = `${Bun.hash(JSON.stringify([read, choice, zone, toLive ?? null, readable])).toString(36)}:${Math.floor(now.getTime() / 60_000)}`;
265 const key = `${Bun.hash(JSON.stringify([read, choice, zone, toLive ?? null, readable, chapters])).toString(36)}:${Math.floor(now.getTime() / 60_000)}`;
161266 const kept = rendered.get(key);
162267 if (kept !== undefined) return kept;
163 const made = renderRegion(read, choice, now, zone, toLive, readable);
268 const made = renderRegion(read, choice, now, zone, toLive, readable, chapters);
164269 if (rendered.size >= REGIONS) rendered.delete(rendered.keys().next().value as string);
165270 rendered.set(key, made);
166271 return made;
@@ -174,6 +279,7 @@ function renderRegion(
174279 zone = "UTC",
175280 toLive?: { version: string; seconds: number },
176281 readable = true,
282 chapters: readonly ChapterEntry[] = [],
177283 ): Html {
178284 const keep = (l: Landing) => choice === "all" || l.repo === choice;
179285 const live = read.live.filter(keep);
@@ -193,10 +299,14 @@ function renderRegion(
193299 onItsWay.length === 0
194300 ? null
195301 : html`<h2 class="mt">On its way</h2><ul class="list setting-list">${onItsWay.map((l) => landingRow(l, read.own, choice === "all"))}</ul>`;
302 const groups = groupLandings(live, chapters, now);
303 const grouped = groups.some((g) => g.kind === "chapter");
196304 const gone =
197305 live.length === 0
198306 ? html`<p class="mt muted">Nothing has shipped yet.</p>`
199 : html`${byDay(live, read.own, choice === "all", zone, todayIs)}`;
307 : !grouped
308 ? html`${byDay(live, read.own, choice === "all", zone, todayIs)}`
309 : html`${groups.map((g) => (g.kind === "chapter" ? chapterSection(g, read.own, choice === "all", zone, todayIs) : byDay(g.landings, read.own, choice === "all", zone, todayIs)))}`;
200310 return html`${top}${way}${gone}`;
201311 }
202312
@@ -211,15 +321,18 @@ export async function changelogPage(ctx: Ctx): Promise<Response> {
211321 const planned = nearMilestones(ctx.app, landed.own ?? ctx.app.config.forgeRepo, ctx.auth.user);
212322 const near = planned?.near ?? [];
213323 const everything = [...read.onItsWay, ...read.live];
324 // One aggregate read stands for the chapters in the tag; the per-repository reads wait until the tag has missed.
325 const stamp = chapterStamp(ctx.app.db);
214326 const choice = chosenRepo(new URL(ctx.request.url).searchParams.get("repo"), everything, read.own);
215327 const others = [...new Set(everything.map((l) => l.repo))].filter((r) => r !== read.own).slice(0, FILTERS);
216328 // The tag comes from what the page would say, before any of it is rendered:
217329 // a match answers 304 without building the page.
218330 const toLive = newestLive(ctx.app.db);
219331 const flashed = readFlash(ctx.request) !== undefined;
220 const tag = flashed ? undefined : changelogTag(read.version, everything, [choice, bet, read.commit, read.live.map((l) => `${l.repo}#${l.number}`), readable, toLive ?? null, everything.map((l) => timeAgo(l.at)), new Date().toISOString().slice(0, 10), ctx.auth.user?.id ?? null, ctx.auth.user?.isAdmin === true, ctx.theme ?? null, near]);
332 const tag = flashed ? undefined : changelogTag(read.version, everything, [choice, bet, read.commit, read.live.map((l) => `${l.repo}#${l.number}`), readable, toLive ?? null, everything.map((l) => timeAgo(l.at)), new Date().toISOString().slice(0, 10), ctx.auth.user?.id ?? null, ctx.auth.user?.isAdmin === true, ctx.theme ?? null, near, stamp]);
221333 const keep = tag === undefined ? undefined : cacheHeaders(ctx, tag);
222334 if (keep !== undefined && keep.matched) return keep.response;
335 const chapters = chapterEntries(ctx.app.db, read.live, ctx.auth.user);
223336 const rendering = performance.now();
224337 const filters = seg([
225338 ...(read.own === undefined ? [] : [{ label: "This forge", href: "/changelog", current: choice === read.own }]),
@@ -234,7 +347,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> {
234347 ...bets.map((b) => ({ label: b, href: `/changelog?bet=${encodeURIComponent(b)}`, current: bet === b })),
235348 ]);
236349 const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${nearBlock(planned === undefined ? "" : repoBase(planned.repo), near)}${filters}${betFilters}${read.own !== undefined && readable ? html`<p class="small"><a href="/${read.own}/waves" data-waves-link>Read it by wave</a></p>` : null}
237 <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", toLive, readable)}</div>`;
350 <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", toLive, readable, chapters)}</div>`;
238351 const response = page({ ctx, title: "Changelog", body, feed: { href: "/changelog.atom", title: "Changelog" }, json: { href: `/api/v1/changelog${choice === read.own ? "" : `?repo=${encodeURIComponent(choice)}`}`, title: "Changelog (JSON)" }, scriptJs: `${liveRegionsJs}\n${localDaysJs}` });
239352 notePhase(ctx.request, "render", performance.now() - rendering);
240353 if (keep === undefined) return response;
test/changelog-chapters.test.ts added +121
@@ -0,0 +1,121 @@
1 /** /changelog groups releases under the chapter they closed (#11868); loose ones stay flat. */
2 import { expect, test } from "bun:test";
3 import type { Chapter } from "../src/core/chapters.ts";
4 import type { Landing, Landings } from "../src/release/landings.ts";
5 import { chapterEntries, chapterStamp, groupLandings, shippedRegion, type ChapterEntry } from "../src/web/pages/changelog.ts";
6 import { noteWords } from "../src/release/chapter_words.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { getRepo } from "../src/core/repos.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { createApp } from "../src/app.ts";
11 import { mkdtempSync } from "node:fs";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14
15 const NOW = new Date("2026-11-12T12:00:00Z");
16 const day = (d: number) => `2026-10-${String(d).padStart(2, "0")}T10:00:00Z`;
17 const land = (number: number, at: string): Landing => ({ repo: "a/b", number, title: `Pull ${number}`, at, live: true });
18 const chapter = (name: string, opened: string, closed: string | null, over: Partial<Chapter> = {}): Chapter => ({
19 id: name, repoId: "r", name, means: `${name} means done`, state: closed === null ? "open" : "closed", position: 1, patchOf: null,
20 createdAt: opened, openedAt: opened, closedAt: closed, tagOid: closed === null ? null : "abc123", notes: `# ${name}\n\nShipped.\n`, costUsd: 1, ...over,
21 });
22 const entry = (c: Chapter, canSee = true): ChapterEntry => ({ repo: "a/b", chapter: c, canSee });
23
24 // Newest first: 30, 25 loose; 20, 15 in v2; 10 loose between; 5, 3 in v1; 1 before v1 opened.
25 const landings = [land(30, day(30)), land(25, day(25)), land(20, day(20)), land(15, day(15)), land(10, day(10)), land(5, day(5)), land(3, day(3)), land(1, day(1))];
26 const v1 = chapter("v1.0.0", day(2), day(6));
27 const v2 = chapter("v2.0.0", day(12), day(21));
28
29 test("two chapters and loose releases come out newest first with the right members", () => {
30 const groups = groupLandings(landings, [entry(v1), entry(v2)], NOW);
31 expect(groups.map((g) => (g.kind === "chapter" ? g.chapter.name : "loose"))).toEqual(["loose", "v2.0.0", "loose", "v1.0.0", "loose"]);
32 expect(groups.map((g) => g.landings.map((l) => l.number))).toEqual([[30, 25], [20, 15], [10], [5, 3], [1]]);
33 });
34
35 test("a chapter with no release yet is not drawn, nor is one still open or closing after now", () => {
36 const empty = chapter("v3.0.0", day(31), "2026-10-31T23:00:00Z");
37 const later = chapter("v9.0.0", day(1), "2027-01-01T00:00:00Z");
38 const groups = groupLandings(landings, [entry(empty), entry(later), entry(chapter("v4", day(1), null))], NOW);
39 expect(groups).toEqual([{ kind: "loose", landings }]);
40 });
41
42 test("a chapter of a repository the reader may not see is not shown", () => {
43 const groups = groupLandings(landings, [entry(v1, false), entry(v2)], NOW);
44 expect(groups.flatMap((g) => (g.kind === "chapter" ? [g.chapter.name] : []))).toEqual(["v2.0.0"]);
45 });
46
47 const read = (live: Landing[]): Landings => ({ version: "1.0.0", commit: null, own: "a/b", live, onItsWay: [] });
48
49 test("the region draws the chapter as a row: plain words from the note, no markdown, escaped, each pull under its day", () => {
50 const html = String(shippedRegion(read(landings), "a/b", NOW, "UTC", undefined, true, [entry(chapter("v2.0.0", day(12), day(21), { notes: "# v2\n\n**Shipped** the <b>x</b> thing. More later.\n" }))]));
51 expect(html).toContain('data-chapter="v2.0.0"');
52 expect(html).toContain("v2.0.0 means done");
53 expect(html).toContain("Shipped the x thing.");
54 expect(html).not.toContain("More later");
55 expect(html).not.toContain("# v2");
56 expect(html).not.toContain("**");
57 expect(html).not.toContain("<pre");
58 expect(html).not.toContain("<b>");
59 // The dates stay: a day heading inside the chapter for each day its pulls landed.
60 const inside = html.slice(html.indexOf('data-chapter="v2.0.0"'));
61 expect(inside).toContain('data-day="2026-10-20"');
62 expect(inside).toContain('data-day="2026-10-15"');
63 expect(String(shippedRegion(read(landings), "a/b", NOW))).not.toContain("data-chapter");
64 });
65
66 test("noteWords keeps the first sentence as plain words", () => {
67 expect(noteWords("# Title\n\n- **Fast** [docs](http://x) and `code`. Second.")).toBe("Fast docs and code.");
68 expect(noteWords("# only a heading\n")).toBe("");
69 expect(noteWords("x".repeat(500)).length).toBeLessThanOrEqual(200);
70 });
71
72 test("chapterStamp moves when a chapter closes and is the same otherwise", async () => {
73 const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-chap-")), port: 0 });
74 try {
75 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "own", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
76 if (!u.ok) throw new Error(u.error);
77 const made = await createRepo(app, u.value, { name: "r", visibility: "public" });
78 if (!made.ok) throw new Error("repo");
79 const repo = getRepo(app.db, "own", "r")!;
80 const before = chapterStamp(app.db);
81 expect(chapterStamp(app.db)).toBe(before);
82 closed(app, repo.id, "v1");
83 expect(chapterStamp(app.db)).not.toBe(before);
84 } finally {
85 app.close();
86 }
87 });
88
89 function closed(app: ReturnType<typeof createApp>, repoId: string, name: string): void {
90 app.db
91 .query(`INSERT INTO chapter (id, repo_id, name, means, state, position, created_at, opened_at, closed_at, tag_oid, notes) VALUES (?1, ?2, ?3, 'm', 'closed', 1, ?4, ?4, ?5, 'abc', 'Done.')`)
92 .run(`c-${name}-${repoId}`, repoId, name, day(1), day(6));
93 }
94
95 test("a private repository's chapter is read by its owner and by no outsider", async () => {
96 const app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "forge-chap-")), port: 0 });
97 try {
98 const mk = async (username: string, first = false) => {
99 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: first });
100 if (!u.ok) throw new Error(u.error);
101 return u.value;
102 };
103 const owner = await mk("own", true);
104 const outsider = await mk("out");
105 const made = await createRepo(app, owner, { name: "secret", visibility: "private" });
106 if (!made.ok) throw new Error(JSON.stringify(made.error));
107 const repo = getRepo(app.db, "own", "secret")!;
108 closed(app, repo.id, "v1");
109 const mine = [{ repo: "own/secret", number: 3, title: "Pull 3", at: day(4), live: true }];
110 const seen = (u: typeof owner | undefined) => groupLandings(mine, chapterEntries(app.db, mine, u), NOW).filter((g) => g.kind === "chapter");
111 expect(seen(owner).map((g) => (g.kind === "chapter" ? g.chapter.name : ""))).toEqual(["v1"]);
112 expect(seen(outsider)).toEqual([]);
113 expect(seen(undefined)).toEqual([]);
114 // And in the page's words: the outsider's region has no chapter row.
115 const region = (u: typeof owner | undefined) => String(shippedRegion({ version: "1", commit: null, own: "own/secret", live: mine, onItsWay: [] }, "own/secret", NOW, "UTC", undefined, true, chapterEntries(app.db, mine, u)));
116 expect(region(owner)).toContain('data-chapter="v1"');
117 expect(region(outsider)).not.toContain("data-chapter");
118 } finally {
119 app.close();
120 }
121 });
test/tui-changelog.test.ts +19 −1
@@ -9,7 +9,7 @@ import { createApp, type App } from "../src/app.ts";
99 import { createUser } from "../src/core/users.ts";
1010 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
1111 import type { Ctx } from "../src/tui/app/app.ts";
12 import { changelogScreen, creditWords, pullLines } from "../src/tui/screens/changelog.ts";
12 import { changelogLines, changelogScreen, creditWords, pullLines } from "../src/tui/screens/changelog.ts";
1313 import { headlessTerminal } from "../src/tui/term/terminal.ts";
1414 import { quietLogs } from "./helpers/globals.ts";
1515 import { settleApp } from "./helpers/tui.ts";
@@ -115,3 +115,21 @@ test("a release's pulls read plain, and pulls of one shape gather into one line"
115115 expect(lines[0]!.pulls).toHaveLength(3);
116116 expect(lines[0]!.row.meta).toContain("3 pulls");
117117 });
118
119 test("the terminal groups releases under the chapter that closed them, as the page does", () => {
120 const ch = (name: string, opened: string, closedAt: string | null) => ({
121 id: name, repoId: "r", name, means: "m", state: closedAt === null ? "open" : "closed", position: 1, patchOf: null,
122 createdAt: opened, openedAt: opened, closedAt, tagOid: closedAt === null ? null : "abc", notes: "# v1\n\n**Shipped** it. More.", costUsd: null,
123 }) as const;
124 const rel = (version: string, date: string) => ({ version, date, pulls: [] });
125 const lines = changelogLines(
126 [rel("0.4.0", "2026-10-30T00:00:00Z"), rel("0.3.0", "2026-10-20T00:00:00Z"), rel("0.2.0", "2026-10-10T00:00:00Z"), rel("0.1.0", "2026-10-01T00:00:00Z")],
127 [ch("v1", "2026-10-05T00:00:00Z", "2026-10-25T00:00:00Z"), ch("v2", "2026-10-26T00:00:00Z", null)],
128 new Date("2026-11-12T00:00:00Z"),
129 );
130 expect(lines.map((l) => l.row.title)).toEqual(["0.4.0", "Chapter v1", " 0.3.0", " 0.2.0", "0.1.0"]);
131 expect(lines[1]!.row.meta).toContain("2 releases");
132 expect(lines[1]!.row.meta).toContain("Shipped it.");
133 expect(lines[1]!.entry).toBeUndefined();
134 expect(changelogLines([rel("0.4.0", "2026-10-30T00:00:00Z")], [], new Date()).map((l) => l.row.title)).toEqual(["0.4.0"]);
135 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs