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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10837-changelog-reads-like-a-story-same-shape → main 4 commits fork 5 threads · 5 unresolved
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` | ||
| 12 | 12 | |
| 13 | 13 | 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. |
| 14 | 14 | |
| 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 | ||
| 15 | 19 | ## Credit (#9181, #10259) |
| 16 | 20 | |
| 17 | 21 | 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; | |
| 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 @@ | ||
| 5 | 5 | * pull title; Enter lists a release's pulls, and Enter on a pull opens it. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { gather, plainTitle } from "../../story/gather.ts"; | |
| 8 | 9 | import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts"; |
| 9 | 10 | import { readLandings } from "../../release/landings.ts"; |
| 10 | 11 | import { sourceFor } from "../../view/source.ts"; |
| @@ -20,7 +21,7 @@ import { Filter, ListState, ago, drawList, metaOf, type ListRow } from "./common | ||
| 20 | 21 | |
| 21 | 22 | /** Whether a release's version or any pull it carried names the word. */ |
| 22 | 23 | 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")); | |
| 24 | 25 | } |
| 25 | 26 | |
| 26 | 27 | export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] { |
| @@ -29,7 +30,7 @@ export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] { | ||
| 29 | 30 | meta: metaOf( |
| 30 | 31 | ago(e.date), |
| 31 | 32 | `${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("; "), | |
| 33 | 34 | ), |
| 34 | 35 | })); |
| 35 | 36 | } |
| @@ -42,14 +43,26 @@ export function creditWords(by: string | undefined, withNames: readonly string[] | ||
| 42 | 43 | return parts.filter((p) => p !== "").join(", "); |
| 43 | 44 | } |
| 44 | 45 | |
| 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 { | |
| 47 | 59 | const state = new ListState(); |
| 60 | const lines = pullLines(entry.pulls, credits); | |
| 48 | 61 | return { |
| 49 | title: entry.version, | |
| 62 | title, | |
| 50 | 63 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open pull"), binding(["esc", "q"], "back")], |
| 51 | 64 | 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); | |
| 53 | 66 | if (rows.length === 0) { |
| 54 | 67 | emptyState(s, area, ctx.theme.c, { title: "No pulls", body: "This release carried none." }); |
| 55 | 68 | return; |
| @@ -59,10 +72,11 @@ function releasePulls(f: Session, ctx: Ctx, repoRef: Repo, entry: ReleaseEntry, | ||
| 59 | 72 | key(e) { |
| 60 | 73 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 61 | 74 | 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); | |
| 66 | 80 | return true; |
| 67 | 81 | }, |
| 68 | 82 | }; |
src/web/feeds.ts +2 −1
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | * private repository's feed is for its readers, like its pages. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { plainTitle } from "../story/gather.ts"; | |
| 17 | 18 | import { eventActor, eventVerb } from "../design/activity.ts"; |
| 18 | 19 | import { audienceFilter } from "../core/event_audience.ts"; |
| 19 | 20 | import { listEvents, type ForgeEvent } from "../core/events.ts"; |
| @@ -225,7 +226,7 @@ export async function changelogAtom(ctx: Ctx): Promise<Response> { | ||
| 225 | 226 | const link = `${origin}/${l.repo}/pulls/${l.number}`; |
| 226 | 227 | return ` <entry> |
| 227 | 228 | <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. |
||
| 229 | 230 | <updated>${xml(stamp(l.at))}</updated> |
| 230 | 231 | <link rel="alternate" type="text/html" href="${xml(link)}"/> |
| 231 | 232 | </entry>`; |
src/web/pages/changelog.ts +21 −5
| @@ -14,6 +14,7 @@ import { canRead, getRepo, type Repo } from "../../core/repos.ts"; | ||
| 14 | 14 | import { newestLive, secondsWords } from "../../keeper/landed.ts"; |
| 15 | 15 | import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts"; |
| 16 | 16 | import { chosenRepo, readLandings, type Landing, type Landings } from "../../release/landings.ts"; |
| 17 | import { gather, plainTitle, type Gathered } from "../../story/gather.ts"; | |
| 17 | 18 | import { liveRegionsJs } from "../client.ts"; |
| 18 | 19 | import { pageHead } from "../chrome.ts"; |
| 19 | 20 | import { changelogTag, holds } from "../feeds.ts"; |
| @@ -32,19 +33,33 @@ function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Htm | ||
| 32 | 33 | return row({ |
| 33 | 34 | href: `/${l.repo}/pulls/${l.number}`, |
| 34 | 35 | nav: true, |
| 35 | title: l.title, | |
| 36 | title: plainTitle(l.title), | |
| 36 | 37 | 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}`}`, |
| 37 | 38 | facts: [...(l.by === undefined ? [] : [{ label: "By", value: l.by }]), ...(l.with === undefined ? [] : [{ label: "With", value: l.with.join(", ") }])], |
| 38 | 39 | trail: time(l.at), |
| 39 | 40 | }); |
| 40 | 41 | } |
| 41 | 42 | |
| 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 | ||
| 42 | 57 | /** Day sections in `zone`; the browser regroups them in its own (see `localDaysJs`), so the zone is named while it has not. */ |
| 43 | 58 | function byDay(list: readonly Landing[], own: string | undefined, showRepo: boolean, zone: string, today: string): Html[] { |
| 44 | 59 | const days = new Map<string, Landing[]>(); |
| 45 | 60 | for (const l of list) days.set(dayOf(l.at, zone), [...(days.get(dayOf(l.at, zone)) ?? []), l]); |
| 46 | 61 | 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>`, | |
| 48 | 63 | ); |
| 49 | 64 | } |
| 50 | 65 | |
| @@ -64,7 +79,7 @@ export const localDaysJs = /* js */ ` | ||
| 64 | 79 | if (!cards.length && !top) return; |
| 65 | 80 | var today = dayOf(new Date().toISOString()); |
| 66 | 81 | 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); }); }); | |
| 68 | 83 | var seen = {}, order = []; |
| 69 | 84 | rows.forEach(function (li) { |
| 70 | 85 | var t = li.querySelector('time[datetime]'); |
| @@ -73,6 +88,7 @@ export const localDaysJs = /* js */ ` | ||
| 73 | 88 | if (!seen[d]) { seen[d] = []; order.push(d); } |
| 74 | 89 | seen[d].push(li); |
| 75 | 90 | }); |
| 91 | function count(list) { return list.reduce(function (n, li) { return n + (Number(li.getAttribute('data-count')) || 1); }, 0); } | |
| 76 | 92 | if (cards.length) { |
| 77 | 93 | var proto = cards[0]; |
| 78 | 94 | var after = proto.previousSibling; |
| @@ -89,7 +105,7 @@ export const localDaysJs = /* js */ ` | ||
| 89 | 105 | while (ul.firstChild) ul.removeChild(ul.firstChild); |
| 90 | 106 | seen[d].forEach(function (li) { ul.appendChild(li); }); |
| 91 | 107 | var n = c.querySelector('.badge'); |
| 92 | if (n) n.textContent = String(seen[d].length); | |
| 108 | if (n) n.textContent = String(count(seen[d])); | |
| 93 | 109 | return c; |
| 94 | 110 | }); |
| 95 | 111 | cards.forEach(function (c) { parent.removeChild(c); }); |
| @@ -99,7 +115,7 @@ export const localDaysJs = /* js */ ` | ||
| 99 | 115 | // An empty #shipped-today with no cards is the server saying nothing has ever shipped: |
| 100 | 116 | // writing the count here would bring back the second empty state. |
| 101 | 117 | if (top && (cards.length || top.textContent)) { |
| 102 | var n = (seen[today] || []).length; | |
| 118 | var n = count(seen[today] || []); | |
| 103 | 119 | top.textContent = shippedToday(n, false); |
| 104 | 120 | } |
| 105 | 121 | } |
test/changelog-atom.test.ts +6 −2
| @@ -37,7 +37,9 @@ describe("GET /changelog.atom", () => { | ||
| 37 | 37 | insertPull.run("i1", made.value.defaultBranch, "2026-10-01T10:00:00.000Z", "oid-live"); |
| 38 | 38 | insertIssue.run("i2", made.value.id, 2, "Not shipped yet", u.value.id); |
| 39 | 39 | 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); | |
| 41 | 43 | }, 30_000); |
| 42 | 44 | |
| 43 | 45 | afterAll(async () => { |
| @@ -56,7 +58,9 @@ describe("GET /changelog.atom", () => { | ||
| 56 | 58 | expect(res.headers.get("cache-control")).toBe("public, max-age=300"); |
| 57 | 59 | const text = await res.text(); |
| 58 | 60 | 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"); | |
| 60 | 64 | expect(text).toContain(`href="${servers.url}/alice/forge/pulls/1"`); |
| 61 | 65 | expect(text).toContain("Fix <&> escaping"); |
| 62 | 66 | 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", () => { | ||
| 351 | 351 | await window.happyDOM.close(); |
| 352 | 352 | }); |
| 353 | 353 | |
| 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 | ||
| 354 | 376 | test("with no cards and an empty #shipped-today the script leaves the span empty; with text or cards it writes the count", async () => { |
| 355 | 377 | const { Window } = await import("happy-dom"); |
| 356 | 378 | 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"; | ||
| 9 | 9 | import { createUser } from "../src/core/users.ts"; |
| 10 | 10 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 14 | 14 | import { quietLogs } from "./helpers/globals.ts"; |
| 15 | 15 | import { settleApp } from "./helpers/tui.ts"; |
| @@ -102,10 +102,16 @@ test("a private forge repository leaves the terminal's screen empty", async () = | ||
| 102 | 102 | } |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | import { creditWords } from "../src/tui/screens/changelog.ts"; | |
| 106 | ||
| 107 | 105 | test("creditWords says who built a pull in plain words, and nothing when the commit says nothing", () => { |
| 108 | 106 | expect(creditWords("Scout", ["Claude Sonnet 5.5", "Jay"])).toBe("by Scout, with Claude Sonnet 5.5, Jay"); |
| 109 | 107 | expect(creditWords("Scout", [])).toBe("by Scout"); |
| 110 | 108 | expect(creditWords(undefined, undefined)).toBe(""); |
| 111 | 109 | }); |
| 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 | }); |
Principle 8: uppercasing the first character turns 'j/k' into 'J/k'. Skip it when the first word contains a non-letter.