Public: anyone can read this; the crew can change it.
An agent reads the changelog: GET /api/v1/changelog and an MCP kind answer what went live, as /changelog shows it #8392
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8325-an-agent-reads-the-changelog-get-api-v1 → main 3 commits fork 3 threads · 3 unresolved
14 files
changed +190
−22
docs/MCP.md +1 −1
| @@ -143,7 +143,7 @@ for a tunnel and a forge that sleeps. | ||
| 143 | 143 | | `read_file` | a file (utf-8, or base64 for binary) or a directory listing at a ref, as GET …/contents; MP4/MOV/M4A files also carry about: {kind, seconds, width?, height?, says} | | |
| 144 | 144 | | `release_hold` | (admin scope) `{ reason }` or `{ lift }`: hold the release keeper (#7557) with a reason, so it cuts nothing until the hold is lifted; or lift an owner hold by its id. A failed verify's hold is lifted by a verified release after a pull saying `release hold N` lands, not here. A person who may roll back, with an admin-scope token, never an agent | yes | |
| 145 | 145 | | `release_keeper` | `{}`: what the release keeper (#7557) did: its releases newest first (version, commit (short sha), stage — rolling, verifying, verified or rolled_back —, the known-good version it would roll back to, roll probes and non-200s, probes and pages under load, worst ms, seconds dark, reason, landedToLiveSeconds — from the merge of its pull to the new version answering, null until it has (#8042)) and its holds (kind failed or owner, version, reason, probe numbers, the pull that said `release hold N`, lifted); `blocked` says what stops the next cut; for whoever may read /releases | | |
| 146 | | `releases` | `{shipped: true}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows. `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | | | |
| 146 | | `releases` | `{shipped: true, repo?}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows, for `repo` (`all` or `owner/name`; absent is every repository). `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page | | | |
| 147 | 147 | | `repo` | `{ repo }`: one repository's description, visibility, autonomy, `audience { level, sentence }` and `provenance` (where a migrated one came from, `null` when hidden), the same as `GET /api/v1/repos/:owner/:name` | | |
| 148 | 148 | | `repo_labels` | `{ repo, action?, name?, color?, id? }`: list (`list`, the default, paged by `limit` 1–200 and `cursor`, as `{ items, next_cursor }`, each label with `open`, its open issues and pulls, for a writer; a read-only token may), create (`create`, `name`, `color` six hex digits), rename or recolour (`update`, `id`, `name` and/or `color`, as the labels page) or remove (`remove`, `id`) the repository's labels, as `GET`/`POST …/labels` and `PATCH`/`DELETE …/labels/:id`; create, update and remove need an admin token and a repository admin | yes | |
| 149 | 149 | | `repo_settings` | (admin scope) `{ repo, description?, visibility? }` (admin): change a repository's description and visibility as `PATCH /api/v1/repos/:owner/:name` does, for the same callers (a repository admin); answers `{ repo, description, visibility }` (with none to change it only reads); a visibility other than public or private is `invalid` | yes | |
docs/features/changelog.md +4
| @@ -11,3 +11,7 @@ One bounded `git log` read, cached per head oid; Server-Timing names `releases` | ||
| 11 | 11 | `/changelog` lists the pulls merged into a default branch, newest first, grouped by UTC day: title, link, repository, when it landed. A pull to the forge's own repository (`FORGE_REPO`) counts only once the running commit (`FORGE_COMMIT`, the one `/api/v1/health` reports; the default branch head for a forge run from source) contains its merge commit, asked of git with `merge-base --is-ancestor` and kept while live. Landed but not yet contained shows above as "On its way". A pull to any other public repository is live when it lands. The top line says the live version and commit and how many shipped today. `?repo=all` or `?repo=owner/name` filters; the forge's own repository is the default. |
| 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 | ||
| 15 | ## For agents | |
| 16 | ||
| 17 | `GET /api/v1/changelog` is the page's list as JSON, public, cached five seconds: `{version, commit, live, onItsWay}`, each landing `{repo, number, title, at, live}`. `?repo=all` or `?repo=owner/name` filters; the default is the forge's own repository, as on the page. MCP `releases` with `{shipped: true, repo?}` and `GET /api/v1/releases?shipped=true[&repo=]` answer the same rows with the same default. Only public repositories are read; concurrent requests share one read of git and the merged rows, so an anonymous caller costs no more than a page view. The page links the JSON with `rel="alternate"`. The terminal's Changelog screen is the release-commit view (`release/changelog.ts`); it does not show the landings list. |
src/contract/openapi/routes/getApiV1Changelog.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | import type { Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: "/api/v1/changelog", | |
| 6 | summary: "What went live and what is on its way, as /changelog shows it, for anyone: version, commit, live and onItsWay rows (repo, number, title, at) of public repositories; ?repo=all|owner/name, this forge's own by default (all when it has none)", | |
| 7 | auth: false, | |
| 8 | query: { repo: "string" }, | |
| 9 | refusals: {}, | |
| 10 | }; |
src/contract/openapi/routes/index.ts +1
| @@ -50,6 +50,7 @@ export { route as getApiV1BoardsPins } from "./getApiV1BoardsPins.ts"; | ||
| 50 | 50 | export { route as getApiV1BoardsSlug } from "./getApiV1BoardsSlug.ts"; |
| 51 | 51 | export { route as getApiV1BoardsSlugPanelsId } from "./getApiV1BoardsSlugPanelsId.ts"; |
| 52 | 52 | export { route as getApiV1BoardsSources } from "./getApiV1BoardsSources.ts"; |
| 53 | export { route as getApiV1Changelog } from "./getApiV1Changelog.ts"; | |
| 53 | 54 | export { route as getApiV1CloudAccounts } from "./getApiV1CloudAccounts.ts"; |
| 54 | 55 | export { route as getApiV1Contribute } from "./getApiV1Contribute.ts"; |
| 55 | 56 | export { route as getApiV1ConversationTurns } from "./getApiV1ConversationTurns.ts"; |
src/http/api/releases.ts +1 −1
| @@ -16,7 +16,7 @@ export function releasesRoutes(router: Router<Handler>): void { | ||
| 16 | 16 | const shipped = ctx.url.searchParams.get("shipped"); |
| 17 | 17 | if (shipped !== null && shipped !== "true") |
| 18 | 18 | return apiError("invalid_shipped", 422, "shipped is true (what went live) or absent (the kept releases)"); |
| 19 | if (shipped === "true") return json(await shippedAnswer(ctx.app)); | |
| 19 | if (shipped === "true") return json(await shippedAnswer(ctx.app, ctx.url.searchParams.get("repo"))); | |
| 20 | 20 | const answer = releasesAnswer(ctx.app, ctx.auth.user); |
| 21 | 21 | return answer.ok ? json(answer.value) : apiError(answer.error, 403, answer.message); |
| 22 | 22 | }); |
src/http/mcp/tools/releases.ts +3 −3
| @@ -3,12 +3,12 @@ import { bad, good, type Tool } from "../shared.ts"; | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "releases", |
| 6 | doc: "`{shipped: true}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows. `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page", | |
| 6 | doc: "`{shipped: true, repo?}`: what went live, for anyone: `version`, `commit`, `live` (newest first: `repo`, `number`, `title`, `at`) and `onItsWay` (landed, not yet in the running commit), the list /changelog shows, for `repo` (`all` or `owner/name`; absent is the forge's own, as on the page). `{}`: the forge's kept releases newest first: short sha, version, the pull that landed it, when, and for the live one `live: true` and `since`; for whoever may read /releases, others get its refusal; no rollback, a person does that on the page", | |
| 7 | 7 | description: "Kept releases, newest first.", |
| 8 | inputSchema: { type: "object", properties: { shipped: { type: "boolean" } } }, | |
| 8 | inputSchema: { type: "object", properties: { shipped: { type: "boolean" }, repo: { type: "string" } } }, | |
| 9 | 9 | writes: false, |
| 10 | 10 | async run(ctx, user, args) { |
| 11 | if (args["shipped"] === true) return good(await shippedAnswer(ctx.app)); | |
| 11 | if (args["shipped"] === true) return good(await shippedAnswer(ctx.app, typeof args["repo"] === "string" ? args["repo"] : null)); | |
| 12 | 12 | // The page's own gate: its refusal, in words. |
| 13 | 13 | const answer = releasesAnswer(ctx.app, user); |
| 14 | 14 | return answer.ok ? good(answer.value) : bad(answer.error, answer.message); |
src/http/server.ts +3 −1
| @@ -171,7 +171,7 @@ import { | ||
| 171 | 171 | domainsMoved, |
| 172 | 172 | } from "../web/pages/backups.ts"; |
| 173 | 173 | import { apiPage, appApiPage } from "../web/pages/api.ts"; |
| 174 | import { changelogPage } from "../web/pages/changelog.ts"; | |
| 174 | import { changelogJson, changelogPage } from "../web/pages/changelog.ts"; | |
| 175 | 175 | import { releaseHoldSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts"; |
| 176 | 176 | import { |
| 177 | 177 | cloudAdd, |
| @@ -1568,6 +1568,8 @@ export function buildApiRouter(): Router<Handler> { | ||
| 1568 | 1568 | apiRoutes(router); |
| 1569 | 1569 | feedRoutes(router); |
| 1570 | 1570 | checksRecheckRoutes(router); |
| 1571 | // What went live, as /changelog shows it; the page's own module answers. | |
| 1572 | router.add("GET", "/api/v1/changelog", changelogJson); | |
| 1571 | 1573 | // The reference page for these routes lives at their root, so it answers here. |
| 1572 | 1574 | router.add("GET", "/api", apiPage); |
| 1573 | 1575 | return router; |
src/release/answer.ts +21 −5
| @@ -9,7 +9,7 @@ import { getRepoById } from "../core/repos.ts"; | ||
| 9 | 9 | import type { User } from "../core/users.ts"; |
| 10 | 10 | import { running } from "../kernel/version.ts"; |
| 11 | 11 | import { BOOTED, mayRead } from "./gate.ts"; |
| 12 | import { readLandings } from "./landings.ts"; | |
| 12 | import { chosenRepo, readLandings, type Landing, type Landings } from "./landings.ts"; | |
| 13 | 13 | import { listReleases, releaseName } from "./record.ts"; |
| 14 | 14 | |
| 15 | 15 | export const RELEASES_REFUSAL = "the release history is for the forge's administrators and its repository's readers"; |
| @@ -18,10 +18,26 @@ export type ReleasesAnswer = | ||
| 18 | 18 | | { ok: true; value: unknown } |
| 19 | 19 | | { ok: false; error: "forbidden"; message: string }; |
| 20 | 20 | |
| 21 | /** What went live, for anyone: the list /changelog shows. */ | |
| 22 | export async function shippedAnswer(app: App) { | |
| 23 | const { version, commit, live, onItsWay } = await readLandings(app); | |
| 24 | return { version, commit, live, onItsWay }; | |
| 21 | /** Reads in flight, shared: concurrent anonymous requests cost git and the merged rows once. */ | |
| 22 | let reading: Promise<Landings> | undefined; | |
| 23 | function readOnce(app: App): Promise<Landings> { | |
| 24 | reading ??= readLandings(app).finally(() => { | |
| 25 | reading = undefined; | |
| 26 | }); | |
| 27 | return reading; | |
| 28 | } | |
| 29 | ||
| 30 | /** | |
| 31 | * What went live, for anyone: the list /changelog shows. `asked` is the | |
| 32 | * page's `?repo=`: "all" or `owner/name`; absent, or not a repository in the | |
| 33 | * list, is the forge's own, as on the page. Only public repositories are | |
| 34 | * ever read (`mergedRows`). | |
| 35 | */ | |
| 36 | export async function shippedAnswer(app: App, asked: string | null = null) { | |
| 37 | const read = await readOnce(app); | |
| 38 | const choice = chosenRepo(asked, [...read.onItsWay, ...read.live], read.own); | |
| 39 | const keep = (l: Landing) => choice === "all" || l.repo === choice; | |
| 40 | return { version: read.version, commit: read.commit, live: read.live.filter(keep), onItsWay: read.onItsWay.filter(keep) }; | |
| 25 | 41 | } |
| 26 | 42 | |
| 27 | 43 | /** The kept releases newest first, for whoever may read /releases; others get its refusal. */ |
src/release/landings.ts +7
| @@ -39,6 +39,13 @@ export interface Landings { | ||
| 39 | 39 | readonly onItsWay: readonly Landing[]; |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | /** Which repository the reader asked for: "" for this forge's own, "all", or one named in the list; anything else is the default. */ | |
| 43 | export function chosenRepo(asked: string | null, all: readonly Landing[], own: string | undefined): string { | |
| 44 | if (asked === "all") return "all"; | |
| 45 | if (asked !== null && all.some((l) => l.repo === asked)) return asked; | |
| 46 | return own === undefined ? "all" : own; | |
| 47 | } | |
| 48 | ||
| 42 | 49 | /** More merged pulls than this are older than anything the page shows. */ |
| 43 | 50 | const ROWS = 300; |
| 44 | 51 | /** Merge commits of the forge's own repository asked of git on one read. */ |
src/web/layout.ts +6
| @@ -99,6 +99,8 @@ export interface PageOptions { | ||
| 99 | 99 | ogImage?: string | undefined; |
| 100 | 100 | /** This page's feed, advertised in the head so a reader can find it. */ |
| 101 | 101 | feed?: { href: string; title: string }; |
| 102 | /** This page's JSON twin, advertised in the head for agents. */ | |
| 103 | json?: { href: string; title: string }; | |
| 102 | 104 | /** Breadcrumb segments rendered in the topbar, e.g. owner / repo. */ |
| 103 | 105 | crumbs?: { label: string; href: string }[]; |
| 104 | 106 | body: Html; |
| @@ -739,6 +741,10 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 739 | 741 | href="${f.href}" |
| 740 | 742 | />`, |
| 741 | 743 | )} |
| 744 | ${when( | |
| 745 | options.json, | |
| 746 | (j) => html`<link rel="alternate" type="application/json" title="${j.title}" href="${j.href}" />`, | |
| 747 | )} | |
| 742 | 748 | <meta name="color-scheme" content="light dark" /> |
| 743 | 749 | ${theme |
| 744 | 750 | ? html`<meta name="theme-color" content="${metaThemeColor(theme)}" />` |
src/web/pages/changelog.ts +10 −9
| @@ -11,9 +11,11 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 11 | 11 | import { canRead, getRepo } from "../../core/repos.ts"; |
| 12 | 12 | import { newestLive, secondsWords } from "../../keeper/landed.ts"; |
| 13 | 13 | import { dayHeading, dayOf, daysJs, shippedToday } from "../../release/days.ts"; |
| 14 | import { readLandings, type Landing, type Landings } from "../../release/landings.ts"; | |
| 14 | import { chosenRepo, readLandings, type Landing, type Landings } from "../../release/landings.ts"; | |
| 15 | 15 | import { liveRegionsJs } from "../client.ts"; |
| 16 | 16 | import { pageHead } from "../chrome.ts"; |
| 17 | import { json } from "../../http/respond.ts"; | |
| 18 | import { shippedAnswer } from "../../release/answer.ts"; | |
| 17 | 19 | import { html, type Html } from "../html.ts"; |
| 18 | 20 | import { page } from "../layout.ts"; |
| 19 | 21 | import { badge, card, empty, listCard, row, seg, sha, time } from "../ui.ts"; |
| @@ -23,13 +25,6 @@ const LIVE = "pull.merged,app.serving"; | ||
| 23 | 25 | /** Other repositories offered as filters: more than this is a search, not a chip. */ |
| 24 | 26 | const FILTERS = 6; |
| 25 | 27 | |
| 26 | /** Which repository the reader asked for: "" for this forge's own, "all", or one named in the list; anything else is the default. */ | |
| 27 | export function chosenRepo(asked: string | null, all: readonly Landing[], own: string | undefined): string { | |
| 28 | if (asked === "all") return "all"; | |
| 29 | if (asked !== null && all.some((l) => l.repo === asked)) return asked; | |
| 30 | return own === undefined ? "all" : own; | |
| 31 | } | |
| 32 | ||
| 33 | 28 | function landingRow(l: Landing, own: string | undefined, showRepo: boolean): Html { |
outdated
Drop this re-export; import chosenRepo from release/landings.ts where it is used. |
||
| 34 | 29 | return row({ |
| 35 | 30 | href: `/${l.repo}/pulls/${l.number}`, |
| @@ -163,7 +158,13 @@ export async function changelogPage(ctx: Ctx): Promise<Response> { | ||
| 163 | 158 | const readable = ownRepo !== undefined && canRead(ctx.app.db, ownRepo, ctx.auth.user); |
| 164 | 159 | const body = html`${pageHead("Changelog", { sub: "What went live, as it happens." })}${filters} |
| 165 | 160 | <div id="shipped" data-live="${LIVE}" aria-live="polite">${shippedRegion(read, choice, new Date(), "UTC", newestLive(ctx.app.db), readable)}</div>`; |
| 166 | const response = page({ ctx, title: "Changelog", body, scriptJs: `${liveRegionsJs}\n${localDaysJs}` }); | |
| 161 | const response = page({ ctx, title: "Changelog", body, json: { href: `/api/v1/changelog${choice === read.own ? "" : `?repo=${encodeURIComponent(choice)}`}`, title: "Changelog (JSON)" }, scriptJs: `${liveRegionsJs}\n${localDaysJs}` }); | |
| 167 | 162 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 168 | 163 | return response; |
| 169 | 164 | } |
| 165 | ||
| 166 | // GET /api/v1/changelog?repo=all|owner/name: the page's rows as JSON, public repositories only. | |
| 167 | export async function changelogJson(ctx: Ctx): Promise<Response> { | |
| 168 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 169 | return json(await shippedAnswer(ctx.app, ctx.url.searchParams.get("repo")), 200, { "cache-control": "public, max-age=5" }); | |
| 170 | } |
test/changelog-api.test.ts added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | /** `GET /api/v1/changelog` and MCP `releases {shipped, repo}` answer the rows /changelog shows; a private repo's landing is never in them. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createToken } from "../src/core/tokens.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { forgetLive, keepLive } from "../src/release/landings.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | ||
| 13 | describe("GET /api/v1/changelog", () => { | |
| 14 | let dataDir: string; | |
| 15 | let app: App; | |
| 16 | let servers: Servers; | |
| 17 | let admin: string; | |
| 18 | const saved = process.env["FORGE_COMMIT"]; | |
| 19 | const get = (query: string, token?: string) => | |
| 20 | fetch(`${servers.url}/api/v1/changelog${query}`, token ? { headers: { authorization: `Bearer ${token}` } } : {}); | |
| 21 | const mcp = async (args: object, token: string) => { | |
| 22 | const res = await fetch(`${servers.url}/mcp`, { | |
| 23 | method: "POST", | |
| 24 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 25 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "releases", arguments: args } }), | |
| 26 | }); | |
| 27 | const body: any = await res.json(); | |
| 28 | return JSON.parse(body.result.content[0].text); | |
| 29 | }; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | process.env["FORGE_COMMIT"] = "a".repeat(40); | |
| 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-changelog-api-")); | |
| 34 | app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" }); | |
| 35 | servers = await serve(app); | |
| 36 | const u = await createUser(app.db, { | |
| 37 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true, | |
| 38 | }); | |
| 39 | if (!u.ok) throw new Error(u.error); | |
| 40 | admin = createToken(app.db, u.value.id, "a", "admin").token; | |
| 41 | const insertIssue = app.db.query( | |
| 42 | `INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES (?1, ?2, ?3, 'pull', ?4, ?5, 'closed')`, | |
| 43 | ); | |
| 44 | const insertPull = app.db.query( | |
| 45 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at, merged_oid) VALUES (?1, ?2, 'b', 'base', '', ?3, ?4)`, | |
| 46 | ); | |
| 47 | let k = 0; | |
| 48 | for (const [name, visibility, title] of [ | |
| 49 | ["forge", "public", "Public landing"], | |
| 50 | ["notes", "public", "Other repo landing"], | |
| 51 | ["secret", "private", "Private landing"], | |
| 52 | ] as const) { | |
| 53 | const made = await createRepo(app, u.value, { name, visibility }); | |
| 54 | if (!made.ok) throw new Error(String(made.error)); | |
| 55 | k++; | |
| 56 | insertIssue.run(`i${k}`, made.value.id, 1, title, u.value.id); | |
| 57 | insertPull.run(`i${k}`, made.value.defaultBranch, `2026-10-0${k}T10:00:00.000Z`, `oid-${k}`); | |
| 58 | } | |
| 59 | }, 30_000); | |
| 60 | ||
| 61 | afterAll(async () => { | |
| 62 | if (saved === undefined) delete process.env["FORGE_COMMIT"]; | |
| 63 | else process.env["FORGE_COMMIT"] = saved; | |
| 64 | servers.stop(); | |
| 65 | app.close(); | |
| 66 | await rm(dataDir, { recursive: true, force: true }); | |
| 67 | }); | |
| 68 | ||
| 69 | const titles = (b: any) => [...b.live, ...b.onItsWay].map((l: any) => l.title).sort(); | |
| 70 | ||
| 71 | test("answers anyone with the page's shape", async () => { | |
| 72 | const res = await get("?repo=all"); | |
| 73 | expect(res.status).toBe(200); | |
| 74 | const b: any = await res.json(); | |
| 75 | expect(b.commit).toBe("a".repeat(40)); | |
| 76 | expect(typeof b.version).toBe("string"); | |
| 77 | const row = [...b.live, ...b.onItsWay][0]; | |
| 78 | expect(Object.keys(row).sort()).toEqual(["at", "live", "number", "repo", "title"]); | |
| 79 | }); | |
| 80 | ||
| 81 | test("the repo filter: all, one, and everything when the forge has no repository of its own", async () => { | |
| 82 | expect(titles(await (await get("?repo=all")).json())).toEqual(["Other repo landing", "Public landing"]); | |
| 83 | expect(titles(await (await get("?repo=alice/notes")).json())).toEqual(["Other repo landing"]); | |
| 84 | expect(titles(await (await get("")).json())).toEqual(["Other repo landing", "Public landing"]); | |
| 85 | expect(titles(await (await get("?repo=nobody/nothing")).json())).toEqual(["Other repo landing", "Public landing"]); | |
| 86 | expect(titles(await (await get("?repo=alice/forge")).json())).toEqual(["Public landing"]); | |
| 87 | }); | |
| 88 | ||
| 89 | test("with a repository of its own the default is that repository, on every door", async () => { | |
| 90 | keepLive("alice/forge", [], 0); | |
| 91 | try { | |
| 92 | expect(titles(await (await get("")).json())).toEqual(["Public landing"]); | |
| 93 | expect(titles(await mcp({ shipped: true }, admin))).toEqual(["Public landing"]); | |
| 94 | expect(titles(await (await fetch(`${servers.url}/api/v1/releases?shipped=true`)).json())).toEqual(["Public landing"]); | |
| 95 | expect(titles(await (await get("?repo=all")).json())).toEqual(["Other repo landing", "Public landing"]); | |
| 96 | expect((await get("")).headers.get("cache-control")).toBe("public, max-age=5"); | |
| 97 | } finally { | |
| 98 | forgetLive(); | |
| 99 | } | |
| 100 | }); | |
| 101 | ||
| 102 | test("a private repo's landing is hidden, even when named, from anonymous and from its owner's token", async () => { | |
| 103 | for (const q of ["?repo=all", "?repo=alice/secret"]) { | |
| 104 | expect(JSON.stringify(await (await get(q)).json())).not.toContain("Private landing"); | |
| 105 | expect(JSON.stringify(await (await get(q, admin)).json())).not.toContain("Private landing"); | |
| 106 | } | |
| 107 | }); | |
| 108 | ||
| 109 | test("MCP releases returns the same rows", async () => { | |
| 110 | for (const repo of ["all", "alice/notes"]) { | |
| 111 | const viaMcp = await mcp({ shipped: true, repo }, admin); | |
| 112 | expect(viaMcp).toEqual(await (await get(`?repo=${repo}`)).json()); | |
| 113 | } | |
| 114 | }); | |
| 115 | ||
| 116 | test("the page links its JSON twin", async () => { | |
| 117 | const html = await (await fetch(`${servers.url}/changelog`)).text(); | |
| 118 | expect(html).toContain('rel="alternate" type="application/json"'); | |
| 119 | expect(html).toContain('href="/api/v1/changelog?repo=all"'); | |
| 120 | }); | |
| 121 | }); |
test/changelog.test.ts +1 −1
| @@ -125,7 +125,7 @@ describe("changelog page", () => { | ||
| 125 | 125 | }); |
| 126 | 126 | |
| 127 | 127 | test("the same list is the MCP's, for a reader with a token", async () => { |
| 128 | const got = await mcp({ shipped: true }); | |
| 128 | const got = await mcp({ shipped: true, repo: "all" }); | |
| 129 | 129 | expect(got["version"]).toBe("1.2.3"); |
| 130 | 130 | expect(got["commit"]).toBe(oids[1]); |
| 131 | 131 | expect(got["live"].map((l: { title: string }) => l.title)).toEqual(["A note app change", "Second thing", "First <b>thing</b>"]); |
test/design-audit.test.ts +1 −1
| @@ -11,7 +11,7 @@ const doc = readFileSync("docs/design/audit.md", "utf8"); | ||
| 11 | 11 | |
| 12 | 12 | /** Routes that answer a file, a fragment, a redirect hop or a callback. */ |
| 13 | 13 | const NOT_PAGES = |
| 14 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$)/; | |
| 14 | /^\/(assets\/|\.well-known\/|icons\/|og\/|live\/|__forge\/|go\/|events$|system\/stream$|talk\/stream$|settings\/teams\/stream$|sw\.js$|robots\.txt$|favicon\.ico$|apple-touch-icon(-precomposed)?\.png$|sitemap\.xml$|manifest\.webmanifest$|opensearch\.xml$|llms\.txt$|releases\.json$|audit\.csv$|fleet\/graph\/live$|fleet\/graph\.csv$|settings\/origins\/callback$|home\/archived\/|settings\/backups\/live$|api\/v1\/)/; | |
| 15 | 15 | |
| 16 | 16 | const getRoutes = [ |
| 17 | 17 | ...server.matchAll(/router\.add\(\s*"GET",\s*"([^"]+)"/g), |
new above
offline
undefinedmeans all here but the HTTP route passes null and gets the forge's own: two defaults for one read. Unify.