Public: anyone can read this; the crew can change it.
`/changelog.atom` entries carry the wave's headline and link to the wave page, so a feed reader gets the story, not the pull titles #11317
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11298-changelog-atom-entries-carry-the-wave-s → main 3 commits fork 2 threads · 2 unresolved
3 files
changed +96
−7
docs/features/changelog.md +1 −1
| @@ -26,6 +26,6 @@ A squash keeps the people behind its commits: the landed message carries every d | ||
| 26 | 26 | |
| 27 | 27 | ## As a feed (#8213) |
| 28 | 28 | |
| 29 | `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. 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"`. | |
| 29 | `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"`. | |
| 30 | 30 | |
| 31 | 31 | 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. |
src/web/feeds.ts +60 −4
| @@ -22,6 +22,10 @@ import { getIssuesByRef } from "../core/issues.ts"; | ||
| 22 | 22 | import { canRead, forkParentFor, getRepo, repoDatesFor } from "../core/repos.ts"; |
| 23 | 23 | import { explore, type ExploreQuery, type ExploreRow } from "../actions/explore.ts"; |
| 24 | 24 | import { getUsersByIds } from "../core/users.ts"; |
| 25 | import { pullRefs, readWaves, wavePath } from "../story/waves.ts"; | |
| 26 | import type { Wave } from "../story/wave.ts"; | |
| 27 | import { refTip } from "../git/read.ts"; | |
| 28 | import { repoDir } from "../git/repos.ts"; | |
| 25 | 29 | import { shippedAnswer } from "../release/answer.ts"; |
| 26 | 30 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| 27 | 31 | import type { Ctx } from "../http/context.ts"; |
| @@ -196,6 +200,35 @@ ${entries.join("\n")} | ||
| 196 | 200 | |
| 197 | 201 | const CHANGELOG_ENTRIES = 30; |
| 198 | 202 | |
| 203 | /** Waves by the forge repository's default-branch tip, so the common request reads no files. */ | |
| 204 | const waveCache = new Map<string, Wave[]>(); | |
| 205 | ||
| 206 | /** | |
| 207 | * The forge's own waves, newest first, and the tip they were read at (the | |
| 208 | * part of the feed's tag that the waves own). A repository with no waves, or | |
| 209 | * any failure reading them, is no waves: the feed falls back, never errors. | |
| 210 | */ | |
| 211 | async function ownWaves(ctx: Ctx): Promise<{ tip: string; waves: Wave[] }> { | |
| 212 | try { | |
| 213 | const [owner = "", name = ""] = (ctx.app.config.forgeRepo ?? "").split("/"); | |
| 214 | const repo = owner && name ? getRepo(ctx.app.db, owner, name) : undefined; | |
| 215 | if (repo === undefined || repo.visibility !== "public") return { tip: "", waves: [] }; | |
| 216 | const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name); | |
| 217 | const tip = (await refTip(dir, `refs/heads/${repo.defaultBranch}`)) ?? ""; | |
| 218 | if (tip === "") return { tip, waves: [] }; | |
| 219 | const key = `${dir}@${tip}`; | |
| 220 | let waves = waveCache.get(key); | |
| 221 | if (waves === undefined) { | |
| 222 | waves = (await readWaves(dir, repo.defaultBranch)).flatMap((e) => (e.ok ? [e.wave] : [])); | |
| 223 | if (waveCache.size > 8) waveCache.clear(); | |
| 224 | waveCache.set(key, waves); | |
| 225 | } | |
| 226 | return { tip, waves }; | |
| 227 | } catch { | |
| 228 | return { tip: "", waves: [] }; | |
| 229 | } | |
| 230 | } | |
| 231 | ||
| 199 | 232 | /** |
| 200 | 233 | * The weak tag of a changelog answer: the live version, the rows' repo, |
| 201 | 234 | * number, title and time, and `scope` (the chosen repo, the viewer when the |
| @@ -220,17 +253,40 @@ export async function changelogAtom(ctx: Ctx): Promise<Response> { | ||
| 220 | 253 | const answer = await timePhaseAsync(ctx.request, "releases", () => shippedAnswer(ctx.app)); |
| 221 | 254 | const origin = ctx.url.origin; |
| 222 | 255 | const live = answer.live.slice(0, CHANGELOG_ENTRIES); |
| 223 | const etag = changelogTag(answer.version, live); | |
| 256 | const { tip, waves } = await timePhaseAsync(ctx.request, "waves", () => ownWaves(ctx)); | |
| 257 | const [ownerName = "", repoName = ""] = (ctx.app.config.forgeRepo ?? "").split("/"); | |
| 258 | const etag = changelogTag(answer.version, live, [tip]); | |
| 224 | 259 | const headers = { etag, "cache-control": "public, max-age=300" }; |
| 225 | 260 | if (holds(ctx.request.headers.get("if-none-match"), etag)) return new Response(null, { status: 304, headers }); |
| 226 | const entries = live.map((l) => { | |
| 261 | const waveOf = new Map<string, Wave>(); | |
| 262 | for (const w of waves) | |
| 263 | for (const r of pullRefs(w)) { | |
| 264 | const k = `${r.owner}/${r.name}#${r.number}`; | |
| 265 | if (!waveOf.has(k)) waveOf.set(k, w); | |
| 266 | } | |
| 267 | const told = new Set<string>(); | |
| 268 | const entries = live.flatMap((l) => { | |
| 269 | const wave = waveOf.get(`${l.repo}#${l.number}`); | |
| 270 | if (wave !== undefined) { | |
| 271 | // One entry per wave, at its newest landing: the story is told once. | |
| 272 | if (told.has(wave.version)) return []; | |
| 273 | told.add(wave.version); | |
| 274 | const summary = wave.highlights.map((h) => `${h.title.trim()}: ${h.why.trim()}`).join(" · "); | |
| 275 | return [` <entry> | |
| 276 | <id>urn:forge:wave:${xml(`${ownerName}/${repoName}@${wave.version}`)}</id> | |
| 277 | <title>${xml(wave.headline.trim())}</title> | |
| 278 | <updated>${xml(stamp(l.at))}</updated> | |
| 279 | <link rel="alternate" type="text/html" href="${xml(`${origin}${wavePath(ownerName, repoName, wave.version)}`)}"/> | |
| 280 | <summary>${xml(summary)}</summary> | |
| 281 | </entry>`]; | |
| 282 | } | |
| 227 | 283 | const link = `${origin}/${l.repo}/pulls/${l.number}`; |
| 228 | return ` <entry> | |
| 284 | return [` <entry> | |
| 229 | 285 | <id>urn:forge:landing:${xml(l.repo)}#${l.number}</id> |
| 230 | 286 | <title>${xml(`Went live: ${plainTitle(l.title)}`)}</title> |
| 231 | 287 | <updated>${xml(stamp(l.at))}</updated> |
| 232 | 288 | <link rel="alternate" type="text/html" href="${xml(link)}"/> |
| 233 | </entry>`; | |
| 289 | </entry>`]; | |
| 234 | 290 | }); |
| 235 | 291 | const updated = live[0]?.at ?? STARTED; |
| 236 | 292 | const body = `<?xml version="1.0" encoding="utf-8"?> |
test/changelog-atom.test.ts +35 −2
| @@ -4,9 +4,11 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { createApp, type App } from "../src/app.ts"; |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 9 | 9 | import { forgetLive, keepLive } from "../src/release/landings.ts"; |
| 10 | import { getRepo } from "../src/core/repos.ts"; | |
outdated
Duplicate imports: fold |
||
| 11 | import { writeWave } from "../src/story/wave.ts"; | |
| 10 | 12 | import { checkAtom } from "./helpers/atom.ts"; |
| 11 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 12 | 14 | |
| @@ -91,6 +93,37 @@ describe("GET /changelog.atom", () => { | ||
| 91 | 93 | expect(again.status).toBe(304); |
| 92 | 94 | }); |
| 93 | 95 | |
| 96 | test("a release a wave covers speaks the wave's headline, escaped, and links the wave; a broken wave falls back", async () => { | |
| 97 | const repo = getRepo(app.db, "alice", "forge")!; | |
| 98 | const user = getUserByName(app.db, "alice")!; | |
| 99 | const w = writeWave({ | |
| 100 | version: "1.2.3", | |
| 101 | date: "2026-10-03", | |
| 102 | headline: "Faster <b>feeds</b> & more", | |
| 103 | highlights: [1, 2, 3].map((n) => ({ title: `Thing ${n} <x>`, why: "because & so" })), | |
| 104 | pulls: [{ title: "#1 Fix", url: `${servers.url}/alice/forge/pulls/1` }], | |
| 105 | }); | |
| 106 | if (!w.ok) throw w.error; | |
| 107 | const before = (await fetch(`${servers.url}/changelog.atom`)).headers.get("etag"); | |
| 108 | const done = await commitToBranch(app, user, repo, { message: "wave", files: [{ path: "waves/1.2.3.md", content: w.value }] }); | |
| 109 | expect(done.ok).toBe(true); | |
| 110 | const res = await fetch(`${servers.url}/changelog.atom`); | |
| 111 | const text = await res.text(); | |
| 112 | const { titles } = checkAtom(text, `${servers.url}/changelog.atom`); | |
| 113 | expect(titles).toEqual(["Faster <b>feeds</b> & more", "Went live: j/k, enter, esc and ? on pulls"]); | |
| 114 | expect(text).toContain(`href="${servers.url}/alice/forge/waves/1.2.3"`); | |
| 115 | expect(text).toContain("<summary>Thing 1 <x>: because & so"); | |
| 116 | expect(text).toContain("Faster <b>feeds</b> & more"); | |
| 117 | expect(text).not.toContain("<b>"); | |
| 118 | expect(res.headers.get("etag")).not.toBe(before); | |
| 119 | ||
| 120 | const bad = await commitToBranch(app, user, repo, { message: "broken", files: [{ path: "waves/1.2.3.md", content: "not a wave" }] }); | |
| 121 | expect(bad.ok).toBe(true); | |
| 122 | const fell = await fetch(`${servers.url}/changelog.atom`); | |
| 123 | expect(fell.status).toBe(200); | |
| 124 | expect((await fell.text())).toContain("Went live: Fix <&> escaping"); | |
| 125 | }); | |
| 126 | ||
| 94 | 127 | test("the page links it", async () => { |
| 95 | 128 | const html = await (await fetch(`${servers.url}/changelog`)).text(); |
| 96 | 129 | expect(html).toMatch(/rel="alternate"\s+type="application\/atom\+xml"[^>]*href="\/changelog\.atom"/); |
This collapses several landings into one entry per wave, which the how-to in docs/features/changelog.md line 29 does not say (it still promises one
Went live:entry per landing). Update that paragraph in this pull.