Public: anyone can read this; the crew can change it.
/changelog groups releases under the chapter they closed #11896
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11868-changelog-groups-releases-under-the-chap → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 16 | 16 | ## Releases as rows (#9753) |
| 17 | 17 | |
| 18 | 18 | `/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 | ||
| 33 | 33 | `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"`. |
| 34 | 34 | |
| 35 | 35 | 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 @@ | ||
| 5 | 5 | * pull title; Enter lists a release's pulls, and Enter on a pull opens it. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { listChapters, type Chapter } from "../../core/chapters.ts"; | |
| 9 | import { inChapter, isDrawable, noteWords } from "../../release/chapter_words.ts"; | |
| 8 | 10 | import { gather, plainTitle } from "../../story/gather.ts"; |
| 9 | 11 | import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts"; |
| 10 | 12 | import { readLandings } from "../../release/landings.ts"; |
| @@ -36,6 +38,36 @@ export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] { | ||
| 36 | 38 | })); |
| 37 | 39 | } |
| 38 | 40 | |
| 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 | ||
| 39 | 71 | /** The open milestones as lines above the releases: "name N of M done", at most `MOST` of them. */ |
| 40 | 72 | const MOST = 5; |
| 41 | 73 | export function nearLines(near: readonly Near[]): string[] { |
| @@ -97,6 +129,8 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen { | ||
| 97 | 129 | let repoRef: Repo | undefined; |
| 98 | 130 | let credits = new Map<number, string>(); |
| 99 | 131 | let near: string[] = []; |
| 132 | let chapters: Chapter[] = []; | |
| 133 | const lines = (): Line[] => changelogLines(shown(), chapters); | |
| 100 | 134 | const shown = (): ReleaseEntry[] => entries.filter((e) => releaseMatches(filter, e)); |
| 101 | 135 | return { |
| 102 | 136 | title: "Changelog", |
| @@ -119,6 +153,7 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen { | ||
| 119 | 153 | if (words !== "" && l.repo.toLowerCase() === repoName) credits.set(l.number, words); |
| 120 | 154 | } |
| 121 | 155 | } |
| 156 | chapters = read === undefined ? [] : listChapters(f.app.db, read.repo.id); | |
| 122 | 157 | near = nearLines(nearMilestones(f.app, f.app.config.forgeRepo, f.user)?.near ?? []); |
| 123 | 158 | state.clamp(entries.length); |
| 124 | 159 | loaded = true; |
| @@ -143,21 +178,22 @@ export function changelogScreen(f: Session, ctx: Ctx): Screen { | ||
| 143 | 178 | emptyState(s, body!, c, { title: "No release matches", body: "Esc clears the word." }); |
| 144 | 179 | return; |
| 145 | 180 | } |
| 146 | drawList(s, body!, c, state, changelogRows(list)); | |
| 181 | const shownLines = lines(); | |
| 182 | drawList(s, body!, c, state, shownLines.map((l) => l.row)); | |
| 147 | 183 | }, |
| 148 | 184 | key(e) { |
| 149 | 185 | if (filter.key(e)) { |
| 150 | state.clamp(shown().length); | |
| 186 | state.clamp(lines().length); | |
| 151 | 187 | return true; |
| 152 | 188 | } |
| 153 | 189 | if (e.key === "esc" && filter.query !== "") { |
| 154 | 190 | filter.query = ""; |
| 155 | state.clamp(shown().length); | |
| 191 | state.clamp(lines().length); | |
| 156 | 192 | } else if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 157 | 193 | else if (e.key === "enter") { |
| 158 | const entry = shown()[state.index]; | |
| 194 | const entry = lines()[state.index]?.entry; | |
| 159 | 195 | 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); | |
| 161 | 197 | return true; |
| 162 | 198 | }, |
| 163 | 199 | }; |
src/web/pages/changelog.ts +118 −5
| @@ -11,6 +11,10 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 11 | 11 | import { betNamesOfPulls } from "../../core/bets.ts"; |
| 12 | 12 | import { sourcesOfPulls } from "../../core/trusted_sources.ts"; |
| 13 | 13 | 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"; | |
| 14 | 18 | import { newestLive, secondsWords } from "../../keeper/landed.ts"; |
| 15 | 19 | import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts"; |
| 16 | 20 | 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 | ||
| 71 | 75 | }); |
| 72 | 76 | } |
| 73 | 77 | |
| 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 | ||
| 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 | ||
| 74 | 176 | /** Day sections in `zone`; the browser regroups them in its own (see `localDaysJs`), so the zone is named while it has not. */ |
| 75 | 177 | function byDay(list: readonly Landing[], own: string | undefined, showRepo: boolean, zone: string, today: string): Html[] { |
| 76 | 178 | const days = new Map<string, Landing[]>(); |
| @@ -91,6 +193,8 @@ export const localDaysJs = /* js */ ` | ||
| 91 | 193 | function regroup() { |
| 92 | 194 | var root = document.getElementById('shipped'); |
| 93 | 195 | 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; | |
| 94 | 198 | var cards = root.querySelectorAll('[data-day]'); |
| 95 | 199 | var top = document.getElementById('shipped-today'); |
| 96 | 200 | if (!cards.length && !top) return; |
| @@ -156,11 +260,12 @@ export function shippedRegion( | ||
| 156 | 260 | zone = "UTC", |
| 157 | 261 | toLive?: { version: string; seconds: number }, |
| 158 | 262 | readable = true, |
| 263 | chapters: readonly ChapterEntry[] = [], | |
| 159 | 264 | ): 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)}`; | |
| 161 | 266 | const kept = rendered.get(key); |
| 162 | 267 | 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); | |
| 164 | 269 | if (rendered.size >= REGIONS) rendered.delete(rendered.keys().next().value as string); |
| 165 | 270 | rendered.set(key, made); |
| 166 | 271 | return made; |
| @@ -174,6 +279,7 @@ function renderRegion( | ||
| 174 | 279 | zone = "UTC", |
| 175 | 280 | toLive?: { version: string; seconds: number }, |
| 176 | 281 | readable = true, |
| 282 | chapters: readonly ChapterEntry[] = [], | |
| 177 | 283 | ): Html { |
| 178 | 284 | const keep = (l: Landing) => choice === "all" || l.repo === choice; |
| 179 | 285 | const live = read.live.filter(keep); |
| @@ -193,10 +299,14 @@ function renderRegion( | ||
| 193 | 299 | onItsWay.length === 0 |
| 194 | 300 | ? null |
| 195 | 301 | : 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"); | |
| 196 | 304 | const gone = |
| 197 | 305 | live.length === 0 |
| 198 | 306 | ? 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)))}`; | |
| 200 | 310 | return html`${top}${way}${gone}`; |
| 201 | 311 | } |
| 202 | 312 | |
| @@ -211,15 +321,18 @@ export async function changelogPage(ctx: Ctx): Promise<Response> { | ||
| 211 | 321 | const planned = nearMilestones(ctx.app, landed.own ?? ctx.app.config.forgeRepo, ctx.auth.user); |
| 212 | 322 | const near = planned?.near ?? []; |
| 213 | 323 | 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); | |
| 214 | 326 | const choice = chosenRepo(new URL(ctx.request.url).searchParams.get("repo"), everything, read.own); |
| 215 | 327 | const others = [...new Set(everything.map((l) => l.repo))].filter((r) => r !== read.own).slice(0, FILTERS); |
| 216 | 328 | // The tag comes from what the page would say, before any of it is rendered: |
| 217 | 329 | // a match answers 304 without building the page. |
| 218 | 330 | const toLive = newestLive(ctx.app.db); |
| 219 | 331 | 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]); | |
| 221 | 333 | const keep = tag === undefined ? undefined : cacheHeaders(ctx, tag); |
| 222 | 334 | if (keep !== undefined && keep.matched) return keep.response; |
| 335 | const chapters = chapterEntries(ctx.app.db, read.live, ctx.auth.user); | |
| 223 | 336 | const rendering = performance.now(); |
| 224 | 337 | const filters = seg([ |
| 225 | 338 | ...(read.own === undefined ? [] : [{ label: "This forge", href: "/changelog", current: choice === read.own }]), |
| @@ -234,7 +347,7 @@ export async function changelogPage(ctx: Ctx): Promise<Response> { | ||
| 234 | 347 | ...bets.map((b) => ({ label: b, href: `/changelog?bet=${encodeURIComponent(b)}`, current: bet === b })), |
| 235 | 348 | ]); |
| 236 | 349 | 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>`; | |
| 238 | 351 | 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}` }); |
| 239 | 352 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 240 | 353 | 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"; | ||
| 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, creditWords, pullLines } from "../src/tui/screens/changelog.ts"; | |
| 12 | import { changelogLines, 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"; |
| @@ -115,3 +115,21 @@ test("a release's pulls read plain, and pulls of one shape gather into one line" | ||
| 115 | 115 | expect(lines[0]!.pulls).toHaveLength(3); |
| 116 | 116 | expect(lines[0]!.row.meta).toContain("3 pulls"); |
| 117 | 117 | }); |
| 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 | }); |
new above
offline
Query per repository on each request, before the 304 check; cache or batch.