Public: anyone can read this; the crew can change it.
MCP `releases` draws a `ui://forge/releases` card: what went live, newest first, each row linked to its pull #11330
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11225-mcp-releases-draws-a-ui-forge-releases-c → main 6 commits fork 2 threads · 2 unresolved
5 files
changed +65
−8
docs/MCP.md +2 −1
| @@ -507,6 +507,7 @@ answers | ||
| 507 | 507 | | `ui://forge/search` | `search` | The query on top, one row per hit (repository, issue, pull, code) with its kind, title and link. | |
| 508 | 508 | | `ui://forge/issues` | `issues` | A repository's issues, one row each: state, pillar, what it waits on, `untrusted` when an outsider wrote it. | |
| 509 | 509 | | `ui://forge/release` | `releases` | Kept releases, the last 10 newest first, each proven, unproven or rolled back, linked to its pull. | |
| 510 | | `ui://forge/releases` | `releases` with `shipped` | What shipped: one linked row per landing, the live version, what is on its way. | | |
| 510 | 511 | |
| 511 | 512 | The Runs card (`runs`) is one row per run, newest first: glyph, kind, the issue or pull and its title, then the state as a word with how long it ran (or has been running), linked to the run page; failed runs lead the status line. The Plan card (`map`) shows one row per pillar with |
| 512 | 513 | its ready, working and waiting counts, and the first five ready issues linked |
| @@ -521,7 +522,7 @@ answers | ||
| 521 | 522 | The Apps card (`apps`) links each `owner/name` row to the app's page on the forge and adds a |
| 522 | 523 | linked production row when the app has an http(s) URL; the `new_app` card links its |
| 523 | 524 | Production row the same way. Any other address stays plain text, never a link. |
| 524 | The Releases card (`releases`, `ui://forge/release`) is one row per kept release, the last 10, newest first (the status line reads `last 10 of N` when there are more), its version (else its short sha), how long ago it landed, the pull that landed it (as the releases page names it, linked to that pull) and one word, `proven` (the keeper verified it), `unproven` (preflight failed, the keeper never cut it or is still verifying it) or `rolled back` (the keeper rolled it back). The keeper's row is looked up by sha, however old. The rows are the `items` of the tool's text, which carry the same `proof` and `pull`; `releases` with `shipped` has no card. | |
| 525 | The Releases card (`releases`, `ui://forge/release`) is one row per kept release, the last 10, newest first (the status line reads `last 10 of N` when there are more), its version (else its short sha), how long ago it landed, the pull that landed it (as the releases page names it, linked to that pull) and one word, `proven` (the keeper verified it), `unproven` (preflight failed, the keeper never cut it or is still verifying it) or `rolled back` (the keeper rolled it back). The keeper's row is looked up by sha, however old. The rows are the `items` of the tool's text, which carry the same `proof` and `pull`; `releases` with `shipped` draws the Shipped card (`ui://forge/releases`). | |
| 525 | 526 | The Forks card (`fork_tree`, `ui://forge/forks`) is one row per repository you may read, nested by a `depth` field the card pads (not leading spaces, which HTML collapses); the value says how it follows: `follows on its own`, `takes updates`, `offers updates` (the card's own wording for the `offers` mode) or `off`. |
| 526 | 527 | `resources/list` and `resources/read` serve one small |
| 527 | 528 | sandboxed page, which draws the `structuredContent.card` model that the |
src/http/mcp/tools/releases.ts +1 −1
| @@ -3,7 +3,7 @@ import { bad, good, type Tool } from "../shared.ts"; | ||
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "releases", |
| 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", | |
| 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 (the `ui://forge/releases` card), 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 | 8 | inputSchema: { type: "object", properties: { shipped: { type: "boolean" }, repo: { type: "string" } } }, |
| 9 | 9 | writes: false, |
src/web/studio/cards.ts +27 −3
| @@ -11,7 +11,7 @@ import { html, raw } from "../html.ts"; | ||
| 11 | 11 | import { timeAgo } from "../ui.ts"; |
| 12 | 12 | import { span, STAGE_LABEL, verdict, type PipelineKey, type PipelineStage } from "../../flow/pipeline.ts"; |
| 13 | 13 | |
| 14 | export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "search" | "issues"; | |
| 14 | export type CardKind = "app" | "run" | "asks" | "wave" | "map" | "feed" | "pull" | "pulls" | "runs" | "asked" | "explore" | "issue" | "flow" | "scorecard" | "crew" | "board" | "forks" | "milestones" | "release" | "releases" | "search" | "issues"; | |
| 15 | 15 | |
| 16 | 16 | export interface CardRow { |
| 17 | 17 | readonly label: string; |
| @@ -44,7 +44,7 @@ const here = (origin: string, path: string): boolean => origin !== "" && /^\/(?! | ||
| 44 | 44 | |
| 45 | 45 | export const CARD_MIME = "text/html;profile=mcp-app"; |
| 46 | 46 | export const CARD_URI = (kind: CardKind): string => `ui://forge/${kind}`; |
| 47 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "search", "issues"]; | |
| 47 | export const CARD_KINDS: readonly CardKind[] = ["app", "run", "asks", "wave", "map", "feed", "pull", "pulls", "runs", "asked", "explore", "issue", "flow", "scorecard", "crew", "board", "forks", "milestones", "release", "releases", "search", "issues"]; | |
| 48 | 48 | |
| 49 | 49 | /** The tool whose answer each card draws. */ |
| 50 | 50 | export const CARD_OF_TOOL: Readonly<Record<string, CardKind>> = { |
| @@ -92,6 +92,7 @@ const CARD_TITLE: Readonly<Record<CardKind, string>> = { | ||
| 92 | 92 | forks: "Forks", |
| 93 | 93 | milestones: "Milestones", |
| 94 | 94 | release: "Releases", |
| 95 | releases: "Shipped", | |
| 95 | 96 | search: "Search", |
| 96 | 97 | issues: "Issues", |
| 97 | 98 | }; |
| @@ -477,7 +478,30 @@ export function cardFor( | ||
| 477 | 478 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; |
| 478 | 479 | } |
| 479 | 480 | if (kind === "release") { |
| 480 | // `releases` answers `{ items: [{ sha, version, pull?, landed, proof, live? }] }`; the shipped list has no `items`, so no card. | |
| 481 | // `releases` answers `{ items: [{ sha, version, pull?, landed, proof, live? }] }`; with `shipped` it answers | |
| 482 | // `{ version, commit, live: [{ repo, number, title, at }], onItsWay }`, the /changelog list. | |
| 483 | if (Array.isArray(obj(value)["live"])) { | |
| 484 | const kind = "releases"; | |
| 485 | const v = obj(value); | |
| 486 | const live = list(v["live"]).map(obj); | |
| 487 | const onItsWay = list(v["onItsWay"]).map(obj); | |
| 488 | const commit = text(v["commit"]); | |
| 489 | status = `live ${text(v["version"])}${commit === "" ? "" : ` · ${commit.slice(0, 7)}`} · ${live.length} shipped · ${onItsWay.length} on its way`; | |
| 490 | const row = (l: Record<string, unknown>, way: boolean): CardRow => { | |
| 491 | const repo = text(l["repo"]); | |
| 492 | const number = text(l["number"]); | |
| 493 | const path = `/${repo}/pulls/${number}`; | |
| 494 | const at = text(l["at"]); | |
| 495 | return { | |
| 496 | label: `#${number} ${text(l["title"])}`, | |
| 497 | value: way ? "on its way" : `${repo}${at === "" ? "" : ` · ${timeAgo(at)}`}`, | |
| 498 | ...(web(origin) && slugOf(repo) && /^[1-9]\d*$/.test(number) && here(origin, path) ? { link: origin + path } : {}), | |
| 499 | }; | |
| 500 | }; | |
| 501 | for (const l of live.slice(0, RELEASE_ROWS)) rows.push(row(l, false)); | |
| 502 | for (const l of onItsWay.slice(0, Math.max(0, RELEASE_ROWS - rows.length))) rows.push(row(l, true)); | |
| 503 | return { kind, title: CARD_TITLE[kind], status, rows, actions }; | |
| 504 | } | |
| 481 | 505 | const items = list(obj(value)["items"]).map(obj); |
| 482 | 506 | if (!Array.isArray(obj(value)["items"])) return undefined; |
| 483 | 507 | const shown = items.slice(0, RELEASE_ROWS); |
test/mcp-releases-card.test.ts +34 −3
| @@ -133,9 +133,40 @@ describe("MCP releases card", () => { | ||
| 133 | 133 | expect(SHELL_JS).not.toContain("innerHTML"); |
| 134 | 134 | }); |
| 135 | 135 | |
| 136 | test("releases with shipped has no card", async () => { | |
| 136 | test("releases with shipped draws the listed releases card: rows match the JSON, each linked", async () => { | |
| 137 | const uris = ((await rpc("resources/list")).resources as any[]).map((r) => r.uri); | |
| 138 | expect(uris).toContain("ui://forge/releases"); | |
| 139 | expect((await rpc("resources/read", { uri: "ui://forge/releases" })).contents[0].uri).toBe("ui://forge/releases"); | |
| 137 | 140 | const res = await rpc("tools/call", { name: "releases", arguments: { shipped: true } }); |
| 138 | expect(res.structuredContent?.card).toBeUndefined(); | |
| 139 | expect(cardFor("releases", { version: "1", live: [], onItsWay: [] })).toBeUndefined(); | |
| 141 | const json = JSON.parse(res.content[0].text); | |
| 142 | const card = res.structuredContent.card; | |
| 143 | expect(card.kind).toBe("releases"); | |
| 144 | expect(uris).toContain(`ui://forge/${card.kind}`); | |
| 145 | expect(card.status).toContain(`live ${json.version}`); | |
| 146 | const all = [...json.live, ...json.onItsWay]; | |
| 147 | expect(card.rows.length).toBe(Math.min(20, all.length)); | |
| 148 | json.live.slice(0, card.rows.length).forEach((l: any, i: number) => { | |
| 149 | expect(card.rows[i].label).toBe(`#${l.number} ${l.title}`); | |
| 150 | expect(card.rows[i].link).toBe(`${servers.url}/${l.repo}/pulls/${l.number}`); | |
| 151 | }); | |
| 152 | }); | |
| 153 | ||
| 154 | test("a changelog longer than the cap is cut to the cap, live rows first", () => { | |
| 155 | const live = Array.from({ length: 25 }, (_, i) => ({ repo: "a/b", number: i + 1, title: `t${i}`, at: "" })); | |
| 156 | const card = cardFor("releases", { version: "1", commit: null, live, onItsWay: [{ repo: "a/b", number: 99, title: "n", at: "" }] }, "https://f.example")!; | |
| 157 | expect(card.kind).toBe("releases"); | |
| 158 | expect(card.rows.length).toBe(10); | |
| 159 | expect(card.rows.every((r) => r.value !== "on its way")).toBe(true); | |
| 160 | }); | |
| 161 | ||
| 162 | test("the shipped card links each row to its pull and keeps a hostile title as text", () => { | |
| 163 | const evil = `<img src=x onerror=alert(1)>`; | |
| 164 | const card = cardFor("releases", { version: "1.0", commit: "abcdef1234", live: [{ repo: "a/b", number: 3, title: evil, at: "" }], onItsWay: [{ repo: "a/b", number: 4, title: "next", at: "" }] }, "https://f.example")!; | |
| 165 | expect(card.status).toBe("live 1.0 · abcdef1 · 1 shipped · 1 on its way"); | |
| 166 | expect(card.rows[0]!.label).toBe(`#3 ${evil}`); | |
| 167 | expect(card.rows[0]!.link).toBe("https://f.example/a/b/pulls/3"); | |
| 168 | expect(card.rows[1]!.value).toBe("on its way"); | |
| 169 | expect(card.kind).toBe("releases"); | |
| 170 | expect(CARD_SHELL("release")).not.toContain(evil); | |
| 140 | 171 | }); |
| 141 | 172 | }); |
test/mcp.test.ts +1
| @@ -1302,6 +1302,7 @@ describe("the forge over MCP", () => { | ||
| 1302 | 1302 | "ui://forge/pull", |
| 1303 | 1303 | "ui://forge/pulls", |
| 1304 | 1304 | "ui://forge/release", |
| 1305 | "ui://forge/releases", | |
| 1305 | 1306 | "ui://forge/run", |
| 1306 | 1307 | "ui://forge/runs", |
| 1307 | 1308 | "ui://forge/scorecard", |
The issue names the card
ui://forge/releases; this reusesui://forge/release. Register the named resource or get the issue changed; the tool doc line also now points atui://forge/release.