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

/changelog reads like a story: same-shape landings of a day gather into one line, and titles drop lane and test-file jargon #10843

Merged opened this

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

Merged

mason/forge:mason/10837-changelog-reads-like-a-story-same-shape → main 4 commits fork 5 threads · 5 unresolved

  • Merged. mason/10837-changelog-reads-like-a-story-same-shape was merged into main as 733d3d4.

Review

Sign in to review this change.
9 files changed +236 −21
docs/features/changelog.md +4
@@ -12,6 +12,10 @@ One bounded `git log` read, cached per head oid; Server-Timing names `releases`
1212
1313 The list is a live region (`pull.merged`, `app.serving`): it swaps in place without a reload. MCP `releases` with `{shipped: true}` answers the same list to anyone. The "no release" alarm reads the same record (`release/landings.ts`), refreshed each minute, and is silent until a read has happened. The older `Release <version>: …` commit read (`release/changelog.ts`) now serves only the terminal's Changelog screen.
1414
15 ## Reads like a story (#10837)
16
17 Titles are shown plain (`plainTitle` in `story/gather.ts`): a leading `small:` and any `test/…` or `src/…` path are dropped, and a first plain word is capitalised (`j/k` is left as written). The pull keeps its real title on its own page. Within one UTC day, landings whose titles share a shape (the same words with one slot of up to three words changing, at least three words shared) gather into one line such as "j/k, enter, esc and ? on …", with the count and who built them said once; on the page the line is a `<details>` (a focusable `<summary>`, no script) that opens to each pull. A title naming a revert or a security fix is never gathered. The terminal's release pulls gather the same way (Enter on a gathered line lists its pulls), and `/changelog.atom` uses the plain titles. `/api/v1/changelog` keeps the raw titles.
18
1519 ## Credit (#9181, #10259)
1620
1721 A squash keeps the people behind its commits: the landed message carries every distinct `Co-authored-by` its commits carried and each commit's author, except the crew's agents (`@agents.invalid`), once per address. `/changelog` then shows "With Jay Moran" on a row for everyone credited on the landed commit beyond whoever landed it (`creditsOf` in `git/coauthors.ts`, one git read, kept per commit); a landing with no one else shows nothing. Only the forge's own repository is read. A row also says who built it: "By Scout" is the author of the landed commit, "With Claude Sonnet 5.5" the names in its trailers (less the committer and the author), both as plain facts under the title, from the same one git read (`buildersOf`). At most the newest 80 landings of the forge's own repository are asked of git, and at most 500 commits are remembered, oldest dropped first. `GET /api/v1/changelog` carries `by` and `with` on a landing that has them. In the terminal, Enter on a release lists its pulls, and a pull the forge landed shows "by Scout, with Claude Sonnet 5.5" beside it.
src/story/gather.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * Reading a day of landings as a story: titles without lane and test-file
3 * jargon, and landings whose titles share a shape (the same words with one
4 * slot changing) gathered into one line. Plain data in, plain data out.
5 */
6
7 /** A pull's title as a reader wants it: no leading `small:` lane, no `test/…` or `src/…` path. */
8 export function plainTitle(title: string): string {
9 let t = title.trim().replace(/^small\s*:\s*/i, "");
10 // A path, with the ", with" / "and" / "in" that introduced it.
11 t = t.replace(/(?:\s*,)?(?:\s+(?:with|and|in|using|via))?\s+`?(?:test|src)\/[^\s,;)`]*`?/gi, "");
12 t = t.replace(/\s*[,;:]\s*$/, "").replace(/\s{2,}/g, " ").trim();
13 if (t === "") return title.trim();
14 // Only a plain word opens with a capital: "j/k" and "#42" are written as they are said.
15 return /^\p{L}+(?:\s|$)/u.test(t) ? t.charAt(0).toUpperCase() + t.slice(1) : t;
outdated

Principle 8: uppercasing the first character turns 'j/k' into 'J/k'. Skip it when the first word contains a non-letter.

16 }
17
18 /** A revert or a security fix is never folded into a line with others. */
19 export function standsAlone(title: string): boolean {
20 return /\brevert(s|ed|ing)?\b|\bsecurity\b|\bCVE-\d|\bvulnerab|\bXSS\b|\bCSRF\b|\bSSRF\b|\binjection\b|\bexploit/i.test(title);
21 }
22
23 /** Longest run of words kept from the start and end; the words left between are the slot. */
24 const SLOT = 3;
25 const SHARED = 3;
26
27 function words(title: string): string[] {

Called for every pair inside gather(); precompute words once per landing before the loops so a day of hundreds of landings stays well inside the 50 ms budget.

28 return plainTitle(title).split(/\s+/);
29 }
30
31 /** The words two titles share at both ends, or null when they are not the same shape. */
32 function shared(a: string[], b: string[]): { head: string[]; tail: string[] } | null {
33 let p = 0;
34 while (p < a.length && p < b.length && a[p] === b[p]) p++;
35 let s = 0;
36 while (s < a.length - p && s < b.length - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++;
37 const slotA = a.length - p - s;
38 const slotB = b.length - p - s;
39 if (slotA < 1 || slotB < 1 || slotA > SLOT || slotB > SLOT) return null;
40 if (p + s < SHARED || p + s <= Math.max(slotA, slotB)) return null;
41 return { head: a.slice(0, p), tail: a.slice(a.length - s) };
42 }
43
44 /** What gathering needs of a landing or of a pull in a release. */
45 export interface Titled {
46 readonly title: string;
47 readonly by?: string | undefined;
48 readonly with?: readonly string[] | undefined;
49 }
50
51 export interface Gathered<T extends Titled = Titled> {
52 /** The shared words with `…` for the slot, when more than one landing is gathered. */
53 readonly shape: string;
54 readonly landings: readonly T[];
55 /** Who built them, said once: authors and credited models across the landings. */
56 readonly by: readonly string[];
57 readonly with: readonly string[];
58 }
59
60 const once = (xs: readonly (string | undefined)[]): string[] => [...new Set(xs.filter((x): x is string => x !== undefined))];
61
62 /**
63 * One day's landings, in their order, as lines: each seed landing gathers the
64 * later ones sharing its shape (two or more make a group, anchored where the
65 * seed stood); reverts and security fixes stay single.
66 */
67 export function gather<T extends Titled>(day: readonly T[]): Gathered<T>[] {
68 const used = new Set<number>();
69 // Each title read once: the pair loop below only compares the words.
70 const said = day.map((l) => words(l.title));
71 const alone = day.map((l) => standsAlone(l.title));
72 const out: Gathered<T>[] = [];
73 day.forEach((seed, i) => {
74 if (used.has(i)) return;
75 used.add(i);
76 const members = [seed];
77 let head: string[] | undefined;
78 let tail: string[] = [];
79 if (!alone[i]) {
80 const sw = said[i]!;
81 for (let j = i + 1; j < day.length; j++) {
82 const other = day[j]!;
83 if (used.has(j) || alone[j]) continue;
84 const match = shared(sw, said[j]!);
85 if (match === null) continue;
86 // Later members must keep the same ends as the group so far.
87 if (head !== undefined && (match.head.length < head.length || match.tail.length < tail.length)) continue;
88 if (head === undefined || match.head.length < head.length || match.tail.length < tail.length) {
89 head = match.head;
90 tail = match.tail;
91 }
92 members.push(other);
93 used.add(j);
94 }
95 }
96 const shape = head === undefined ? plainTitle(seed.title) : [...head, "…", ...tail].join(" ");
97 out.push({ shape, landings: members, by: once(members.map((m) => m.by)), with: once(members.flatMap((m) => m.with ?? [])) });
98 });
99 return out;
100 }
src/tui/screens/changelog.ts +24 −10
@@ -5,6 +5,7 @@
55 * pull title; Enter lists a release's pulls, and Enter on a pull opens it.
66 */
77
8 import { gather, plainTitle } from "../../story/gather.ts";
89 import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts";
910 import { readLandings } from "../../release/landings.ts";
1011 import { sourceFor } from "../../view/source.ts";
@@ -20,7 +21,7 @@ import { Filter, ListState, ago, drawList, metaOf, type ListRow } from "./common
2021
2122 /** Whether a release's version or any pull it carried names the word. */
2223 export function releaseMatches(filter: Filter, e: ReleaseEntry): boolean {
23 return filter.matches([e.version, ...e.pulls.map((p) => p.title)].join("\n"));
24 return filter.matches([e.version, ...e.pulls.map((p) => plainTitle(p.title))].join("\n"));
2425 }
2526
2627 export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] {
@@ -29,7 +30,7 @@ export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] {
2930 meta: metaOf(
3031 ago(e.date),
3132 `${e.pulls.length} ${e.pulls.length === 1 ? "pull" : "pulls"}`,
32 e.pulls.map((p) => `#${p.number} ${p.title}`.trim()).join("; "),
33 e.pulls.map((p) => `#${p.number} ${plainTitle(p.title)}`.trim()).join("; "),
3334 ),
3435 }));
3536 }
@@ -42,14 +43,26 @@ export function creditWords(by: string | undefined, withNames: readonly string[]
4243 return parts.filter((p) => p !== "").join(", ");
4344 }
4445
45 /** One release's pulls, `#N title`; Enter opens the pull as the changelog's Enter once did. */
46 function releasePulls(f: Session, ctx: Ctx, repoRef: Repo, entry: ReleaseEntry, credits: ReadonlyMap<number, string>): Screen {
46 type Pull = ReleaseEntry["pulls"][number];
47
48 /** A release's pulls as lines: pulls of one shape gathered ("Keys on … · 9 pulls"), the rest `#N title`. */
49 export function pullLines(pulls: readonly Pull[], credits: ReadonlyMap<number, string>): { row: ListRow; pulls: readonly Pull[] }[] {
50 return gather(pulls).map((g) =>
51 g.landings.length === 1
52 ? { row: { title: `#${g.landings[0]!.number} ${plainTitle(g.landings[0]!.title)}`.trim(), meta: credits.get(g.landings[0]!.number) ?? "" }, pulls: g.landings }
53 : { row: { title: g.shape, meta: metaOf(`${g.landings.length} pulls`, g.landings.map((p) => `#${p.number}`).join(" ")) }, pulls: g.landings },
54 );
55 }
56
57 /** One release's pulls; Enter opens a pull as the changelog's Enter once did, and a gathered line lists its own pulls. */
58 function releasePulls(f: Session, ctx: Ctx, repoRef: Repo, entry: ReleaseEntry, credits: ReadonlyMap<number, string>, title = entry.version): Screen {
4759 const state = new ListState();
60 const lines = pullLines(entry.pulls, credits);
4861 return {
49 title: entry.version,
62 title,
5063 keys: [binding(["j", "k"], "move"), binding(["enter"], "open pull"), binding(["esc", "q"], "back")],
5164 render(area, s) {
52 const rows = entry.pulls.map((p) => ({ title: `#${p.number} ${p.title}`.trim(), meta: credits.get(p.number) ?? "" }));
65 const rows = lines.map((l) => l.row);
5366 if (rows.length === 0) {
5467 emptyState(s, area, ctx.theme.c, { title: "No pulls", body: "This release carried none." });
5568 return;
@@ -59,10 +72,11 @@ function releasePulls(f: Session, ctx: Ctx, repoRef: Repo, entry: ReleaseEntry,
5972 key(e) {
6073 if (e.key === "esc" || e.key === "q") ctx.pop();
6174 else if (e.key === "enter") {
62 const pull = entry.pulls[state.index];
63 const opened = pull === undefined ? undefined : sourceFor(f.app, f.user, repoRef);
64 if (pull !== undefined && opened?.ok) ctx.push(pullScreen(f, ctx, opened.value, pull.number));
65 } else state.key(e, entry.pulls.length);
75 const line = lines[state.index];
76 const opened = line === undefined ? undefined : sourceFor(f.app, f.user, repoRef);
77 if (line !== undefined && line.pulls.length > 1) ctx.push(releasePulls(f, ctx, repoRef, { ...entry, pulls: line.pulls }, credits, line.row.title));
78 else if (line !== undefined && opened?.ok) ctx.push(pullScreen(f, ctx, opened.value, line.pulls[0]!.number));
79 } else state.key(e, lines.length);
6680 return true;
6781 },
6882 };
src/web/feeds.ts +2 −1
@@ -14,6 +14,7 @@
1414 * private repository's feed is for its readers, like its pages.
1515 */
1616
17 import { plainTitle } from "../story/gather.ts";
1718 import { eventActor, eventVerb } from "../design/activity.ts";
1819 import { audienceFilter } from "../core/event_audience.ts";
1920 import { listEvents, type ForgeEvent } from "../core/events.ts";
@@ -225,7 +226,7 @@ export async function changelogAtom(ctx: Ctx): Promise<Response> {
225226 const link = `${origin}/${l.repo}/pulls/${l.number}`;
226227 return ` <entry>
227228 <id>urn:forge:landing:${xml(l.repo)}#${l.number}</id>
228 <title>${xml(`Went live: ${l.title}`)}</title>
229 <title>${xml(`Went live: ${plainTitle(l.title)}`)}</title>

Box 4: add a test that the Atom entry title is plain.

229230 <updated>${xml(stamp(l.at))}</updated>
230231 <link rel="alternate" type="text/html" href="${xml(link)}"/>
231232 </entry>`;
src/web/pages/changelog.ts +21 −5
@@ -14,6 +14,7 @@ import { canRead, getRepo, type Repo } from "../../core/repos.ts";
1414 import { newestLive, secondsWords } from "../../keeper/landed.ts";
1515 import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts";
1616 import { chosenRepo, readLandings, type Landing, type Landings } from "../../release/landings.ts";
17 import { gather, plainTitle, type Gathered } from "../../story/gather.ts";
1718 import { liveRegionsJs } from "../client.ts";
1819 import { pageHead } from "../chrome.ts";
1920 import { changelogTag, holds } from "../feeds.ts";
@@ -32,19 +33,33 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm
3233 return row({
3334 href: `/${l.repo}/pulls/${l.number}`,
3435 nav: true,
35 title: l.title,
36 title: plainTitle(l.title),
3637 meta: html`${showRepo || l.repo !== own ? html`${l.repo} · ` : null}#${l.number}${l.bet === undefined ? null : html` · ${badge(l.bet, { variant: "outline" })}`}${l.from === undefined ? null : html` · folded in from ${l.from}`}`,
3738 facts: [...(l.by === undefined ? [] : [{ label: "By", value: l.by }]), ...(l.with === undefined ? [] : [{ label: "With", value: l.with.join(", ") }])],
3839 trail: time(l.at),
3940 });
4041 }
4142
43 /** Several landings of one shape as one line, opening in place to each pull; who built them is said once. */
44 function gatheredRow(g: Gathered<Landing>, own: string | undefined, showRepo: boolean, day: string): Html {
45 const first = g.landings[0]!;
46 const credit = [...(g.by.length === 0 ? [] : [g.by.join(", ")]), ...(g.with.length === 0 ? [] : [`with ${g.with.join(", ")}`])].join(" · ");
47 const quiet = g.landings.map(({ by: _by, with: _with, ...l }): Landing => l);
48 return row({
49 title: g.shape,

Both doors: mirror this gathering and plain titles in src/tui/screens/changelog.ts, with a test, and update docs/features/changelog.md.

50 meta: html`${g.landings.length} pulls${credit === "" ? null : html` · ${credit}`}`,
51 trail: time(first.at),
52 data: { count: String(g.landings.length) },
53 fold: { open: false, id: `gathered-${day}-${first.repo.replace(/[^a-z0-9]+/gi, "-")}-${first.number}`, body: html`<ul class="row-children list setting-list">${quiet.map((l) => landingRow(l, own, showRepo))}</ul>` },
54 });
55 }
56
4257 /** Day sections in `zone`; the browser regroups them in its own (see `localDaysJs`), so the zone is named while it has not. */
4358 function byDay(list: readonly Landing[], own: string | undefined, showRepo: boolean, zone: string, today: string): Html[] {
4459 const days = new Map<string, Landing[]>();
4560 for (const l of list) days.set(dayOf(l.at, zone), [...(days.get(dayOf(l.at, zone)) ?? []), l]);
4661 return [...days].map(([day, items]) =>
47 html`<section class="mt" data-day="${day}"><h2><time datetime="${day}">${dayHeading(day, today)}</time>${zone === "UTC" ? html` <span data-zone>${badge("UTC", { variant: "outline" })}</span>` : null} ${badge(String(items.length))}</h2><ul class="list setting-list">${items.map((l) => landingRow(l, own, showRepo))}</ul></section>`,
62 html`<section class="mt" data-day="${day}"><h2><time datetime="${day}">${dayHeading(day, today)}</time>${zone === "UTC" ? html` <span data-zone>${badge("UTC", { variant: "outline" })}</span>` : null} ${badge(String(items.length))}</h2><ul class="list setting-list">${gather(items).map((g) => (g.landings.length === 1 ? landingRow(g.landings[0]!, own, showRepo) : gatheredRow(g, own, showRepo, day)))}</ul></section>`,
4863 );
4964 }
5065
@@ -64,7 +79,7 @@ export const localDaysJs = /* js */ `
6479 if (!cards.length && !top) return;
6580 var today = dayOf(new Date().toISOString());
6681 var rows = [];
67 cards.forEach(function (c) { c.querySelectorAll('li').forEach(function (li) { rows.push(li); }); });
82 cards.forEach(function (c) { c.querySelectorAll(':scope > ul > li').forEach(function (li) { rows.push(li); }); });
6883 var seen = {}, order = [];
6984 rows.forEach(function (li) {
7085 var t = li.querySelector('time[datetime]');
@@ -73,6 +88,7 @@ export const localDaysJs = /* js */ `
7388 if (!seen[d]) { seen[d] = []; order.push(d); }
7489 seen[d].push(li);
7590 });
91 function count(list) { return list.reduce(function (n, li) { return n + (Number(li.getAttribute('data-count')) || 1); }, 0); }
7692 if (cards.length) {
7793 var proto = cards[0];
7894 var after = proto.previousSibling;
@@ -89,7 +105,7 @@ export const localDaysJs = /* js */ `
89105 while (ul.firstChild) ul.removeChild(ul.firstChild);
90106 seen[d].forEach(function (li) { ul.appendChild(li); });
91107 var n = c.querySelector('.badge');
92 if (n) n.textContent = String(seen[d].length);
108 if (n) n.textContent = String(count(seen[d]));
93109 return c;
94110 });
95111 cards.forEach(function (c) { parent.removeChild(c); });
@@ -99,7 +115,7 @@ export const localDaysJs = /* js */ `
99115 // An empty #shipped-today with no cards is the server saying nothing has ever shipped:
100116 // writing the count here would bring back the second empty state.
101117 if (top && (cards.length || top.textContent)) {
102 var n = (seen[today] || []).length;
118 var n = count(seen[today] || []);
103119 top.textContent = shippedToday(n, false);
104120 }
105121 }
test/changelog-atom.test.ts +6 −2
@@ -37,7 +37,9 @@ describe("GET /changelog.atom", () => {
3737 insertPull.run("i1", made.value.defaultBranch, "2026-10-01T10:00:00.000Z", "oid-live");
3838 insertIssue.run("i2", made.value.id, 2, "Not shipped yet", u.value.id);
3939 insertPull.run("i2", made.value.defaultBranch, "2026-10-02T10:00:00.000Z", "oid-new");
40 keepLive("alice/forge", ["oid-live"], 0);
40 insertIssue.run("i3", made.value.id, 3, "small: j/k, enter, esc and ? on pulls, with test/keys-pulls.test.ts", u.value.id);
41 insertPull.run("i3", made.value.defaultBranch, "2026-09-30T10:00:00.000Z", "oid-live3");
42 keepLive("alice/forge", ["oid-live", "oid-live3"], 0);
4143 }, 30_000);
4244
4345 afterAll(async () => {
@@ -56,7 +58,9 @@ describe("GET /changelog.atom", () => {
5658 expect(res.headers.get("cache-control")).toBe("public, max-age=300");
5759 const text = await res.text();
5860 const { titles } = checkAtom(text, `${servers.url}/changelog.atom`);
59 expect(titles).toEqual(["Went live: Fix <&> escaping"]);
61 expect(titles).toEqual(["Went live: Fix <&> escaping", "Went live: j/k, enter, esc and ? on pulls"]);
62 expect(text).not.toContain("small:");
63 expect(text).not.toContain("test/keys");
6064 expect(text).toContain(`href="${servers.url}/alice/forge/pulls/1"`);
6165 expect(text).toContain("Fix &lt;&amp;&gt; escaping");
6266 expect(text).not.toContain("Not shipped yet");
test/changelog.test.ts +22
@@ -351,6 +351,28 @@ describe("the browser regroups the changelog into the reader's days", () => {
351351 await window.happyDOM.close();
352352 });
353353
354 test("a gathered row counts every landing it holds, and its own pulls stay inside it", async () => {
355 const { Window } = await import("happy-dom");
356 const { localDaysJs, shippedRegion } = await import("../src/web/pages/changelog.ts");
357 zone = process.env["TZ"];
358 process.env["TZ"] = "America/Chicago";
359 setSystemTime(new Date("2026-11-03T03:00:00.000Z")); // 9 pm on 2 Nov in Chicago
360 const same = ["issues", "pulls", "apps"].map((page, i) => ({ repo: "a/forge", number: 10 + i, title: `Keys on the ${page} page`, at: `2026-11-03T0${i}:10:00.000Z`, commit: "" }));
361 const gathered = { ...read, live: [...same, rows[1]] };
362 const window = new Window();
363 const doc = window.document as any;
364 doc.body.innerHTML = `<div id="shipped">${String(shippedRegion(gathered, "all", new Date("2026-11-03T03:00:00.000Z")))}</div>`;
365 expect(doc.querySelectorAll("[data-count]").length).toBe(1);
366 new Function("document", localDaysJs)(doc);
367 const cards = [...doc.querySelectorAll("[data-day]")];
368 expect(cards.map((c) => c.getAttribute("data-day"))).toEqual(["2026-11-02"]);
369 expect(cards[0].querySelector(".badge")!.textContent).toBe("4");
370 expect(doc.getElementById("shipped-today")!.textContent).toBe("4 shipped today.");
371 expect(cards[0].querySelectorAll(":scope > ul > li").length).toBe(2);
372 expect(doc.querySelector("[data-count]").querySelectorAll("li").length).toBe(3);
373 await window.happyDOM.close();
374 });
375
354376 test("with no cards and an empty #shipped-today the script leaves the span empty; with text or cards it writes the count", async () => {
355377 const { Window } = await import("happy-dom");
356378 const { localDaysJs } = await import("../src/web/pages/changelog.ts");
test/story-gather.test.ts added +48
@@ -0,0 +1,48 @@
1 import { expect, test } from "bun:test";
2 import { gather, plainTitle, standsAlone } from "../src/story/gather.ts";
3 import type { Landing } from "../src/release/landings.ts";
4 import { shippedRegion } from "../src/web/pages/changelog.ts";
5
6 const pages = ["issues", "pulls", "apps", "crew", "runs", "forks", "people", "notes", "tells"];
7 const keys = (page: string) => `small: j/k, enter, esc and ? on ${page}, with test/keys-${page}.test.ts`;
8 const land = (n: number, title: string, extra: Partial<Landing> = {}): Landing => ({
9 repo: "a/forge", number: n, title, at: `2026-11-10T0${n % 10}:00:00.000Z`, live: true, by: "Scout", with: ["Claude Sonnet 5.5"], ...extra,
10 });
11
12 test("titles drop the lane and test or source paths", () => {
13 // A first word with a non-letter (keys like j/k) is written as it is said; a plain word gets its capital.
14 expect(plainTitle(keys("pulls"))).toBe("j/k, enter, esc and ? on pulls");
15 expect(plainTitle("small: fold the copy")).toBe("Fold the copy");
16 expect(plainTitle("small: Fold the copy in src/web/ui.ts")).toBe("Fold the copy");
17 expect(plainTitle("Add a thing")).toBe("Add a thing");
18 });
19
20 test("the nine keys pulls gather into one line, credited once", () => {
21 const day = pages.map((p, i) => land(i + 1, keys(p)));
22 const lines = gather(day);
23 expect(lines).toHaveLength(1);
24 expect(lines[0]!.landings).toHaveLength(9);
25 expect(lines[0]!.shape).toBe("j/k, enter, esc and ? on …");
26 expect(lines[0]!.by).toEqual(["Scout"]);
27 });
28
29 test("unlike titles stay single; reverts and security fixes stay on their own line", () => {
30 const day = [
31 land(1, "Revert: j/k, enter, esc and ? on pulls"),
32 land(2, "j/k, enter, esc and ? on issues"),
33 land(3, "j/k, enter, esc and ? on apps"),
34 land(4, "Security: j/k, enter, esc and ? on crew"),
35 land(5, "Something else entirely"),
36 ];
37 expect(standsAlone(day[0]!.title)).toBe(true);
38 expect(gather(day).map((g) => g.landings.map((l) => l.number))).toEqual([[1], [2, 3], [4], [5]]);
39 });
40
41 test("the page folds a gathered line in place, with the author said once", () => {
42 const live = pages.map((p, i) => land(i + 1, keys(p)));
43 const out = shippedRegion({ version: "v", commit: null, own: "a/forge", live, onItsWay: [] }, "a/forge").toString();
44 expect(out).toContain("<details");
45 expect(out).toContain("9 pulls");
46 expect(out.match(/Scout/g)?.length).toBe(1);
47 expect(out).not.toContain("small:");
48 });
test/tui-changelog.test.ts +9 −3
@@ -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 } from "../src/tui/screens/changelog.ts";
12 import { 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";
@@ -102,10 +102,16 @@ test("a private forge repository leaves the terminal's screen empty", async () =
102102 }
103103 });
104104
105 import { creditWords } from "../src/tui/screens/changelog.ts";
106
107105 test("creditWords says who built a pull in plain words, and nothing when the commit says nothing", () => {
108106 expect(creditWords("Scout", ["Claude Sonnet 5.5", "Jay"])).toBe("by Scout, with Claude Sonnet 5.5, Jay");
109107 expect(creditWords("Scout", [])).toBe("by Scout");
110108 expect(creditWords(undefined, undefined)).toBe("");
111109 });
110
111 test("a release's pulls read plain, and pulls of one shape gather into one line", () => {
112 const pulls = ["issues", "pulls", "apps"].map((page, i) => ({ number: i + 1, title: `small: j/k, enter, esc and ? on ${page}, with test/keys-${page}.test.ts` }));
113 const lines = pullLines([...pulls, { number: 9, title: "small: Fix the clock" }], new Map());
outdated

Import in the middle of the file; move it to the import block at the top.

114 expect(lines.map((l) => l.row.title)).toEqual(["j/k, enter, esc and ? on …", "#9 Fix the clock"]);
115 expect(lines[0]!.pulls).toHaveLength(3);
116 expect(lines[0]!.row.meta).toContain("3 pulls");
117 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs