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

Merged opened this

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

Merged

scout/forge:scout/11298-changelog-atom-entries-carry-the-wave-s → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/11298-changelog-atom-entries-carry-the-wave-s was merged into main as bff0914.

Review

Sign in to review this change.
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
2626
2727 ## As a feed (#8213)
2828
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"`.
3030
3131 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";
2222 import { canRead, forkParentFor, getRepo, repoDatesFor } from "../core/repos.ts";
2323 import { explore, type ExploreQuery, type ExploreRow } from "../actions/explore.ts";
2424 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";
2529 import { shippedAnswer } from "../release/answer.ts";
2630 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts";
2731 import type { Ctx } from "../http/context.ts";
@@ -196,6 +200,35 @@ ${entries.join("\n")}
196200
197201 const CHANGELOG_ENTRIES = 30;
198202
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
199232 /**
200233 * The weak tag of a changelog answer: the live version, the rows' repo,
201234 * 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> {
220253 const answer = await timePhaseAsync(ctx.request, "releases", () => shippedAnswer(ctx.app));
221254 const origin = ctx.url.origin;
222255 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]);
224259 const headers = { etag, "cache-control": "public, max-age=300" };
225260 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>();

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.

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 }
227283 const link = `${origin}/${l.repo}/pulls/${l.number}`;
228 return ` <entry>
284 return [` <entry>
229285 <id>urn:forge:landing:${xml(l.repo)}#${l.number}</id>
230286 <title>${xml(`Went live: ${plainTitle(l.title)}`)}</title>
231287 <updated>${xml(stamp(l.at))}</updated>
232288 <link rel="alternate" type="text/html" href="${xml(link)}"/>
233 </entry>`;
289 </entry>`];
234290 });
235291 const updated = live[0]?.at ?? STARTED;
236292 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";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 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";
99 import { forgetLive, keepLive } from "../src/release/landings.ts";
10 import { getRepo } from "../src/core/repos.ts";
outdated

Duplicate imports: fold commitToBranch into the createRepo import from actions/repos.ts and getUserByName into the createUser import.

11 import { writeWave } from "../src/story/wave.ts";
1012 import { checkAtom } from "./helpers/atom.ts";
1113 import { serve, type Servers } from "../src/http/server.ts";
1214
@@ -91,6 +93,37 @@ describe("GET /changelog.atom", () => {
9193 expect(again.status).toBe(304);
9294 });
9395
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 &lt;x&gt;: because &amp; so");
116 expect(text).toContain("Faster &lt;b&gt;feeds&lt;/b&gt; &amp; 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 &lt;&amp;&gt; escaping");
125 });
126
94127 test("the page links it", async () => {
95128 const html = await (await fetch(`${servers.url}/changelog`)).text();
96129 expect(html).toMatch(/rel="alternate"\s+type="application\/atom\+xml"[^>]*href="\/changelog\.atom"/);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs