Public: anyone can read this; the crew can change it.

GET /api/v1/releases answers the kept releases, and ?shipped=true what went live, as MCP `releases` does #8026

Merged opened this

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

Merged

mason/forge:mason/8020-get-api-v1-releases-answers-the-kept-rel → main 2 commits fork

  • Merged. mason/8020-get-api-v1-releases-answers-the-kept-rel was merged into main as 21b8e68.

Review

Sign in to review this change.
7 files changed +186 −26
src/contract/openapi/routes/getApiV1Releases.ts added +13
@@ -0,0 +1,13 @@
1 import type { Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: "/api/v1/releases",
6 summary: "The kept releases newest first, for the forge's administrators and its repository's readers; ?shipped=true is what went live and what is on its way, for anyone",
7 auth: false,
8 query: { shipped: "boolean" },
9 refusals: {
10 forbidden: "the release history is for the forge's administrators and its repository's readers",
11 invalid_shipped: "shipped is true or absent (422)",
12 },
13 };
src/contract/openapi/routes/index.ts +1
@@ -71,6 +71,7 @@ export { route as getApiV1OrgsOrg } from "./getApiV1OrgsOrg.ts";
7171 export { route as getApiV1OrgsOrgTeams } from "./getApiV1OrgsOrgTeams.ts";
7272 export { route as getApiV1OrgsOrgTeamsTeamMembers } from "./getApiV1OrgsOrgTeamsTeamMembers.ts";
7373 export { route as getApiV1PassportHealth } from "./getApiV1PassportHealth.ts";
74 export { route as getApiV1Releases } from "./getApiV1Releases.ts";
7475 export { route as getApiV1Repos } from "./getApiV1Repos.ts";
7576 export { route as getApiV1ReposOwnerName } from "./getApiV1ReposOwnerName.ts";
7677 export { route as getApiV1ReposOwnerNameApp } from "./getApiV1ReposOwnerNameApp.ts";
src/http/api.ts +2
@@ -311,6 +311,7 @@ import { COPIES_PER_DAY, makeItYours } from "../actions/make_it_yours.ts";
311311 import { passportRoutes } from "./passport_edge.ts";
312312 import { egressGrantRoutes } from "./api/egress_grants.ts";
313313 import { forgeRoutes } from "./api/forges.ts";
314 import { releasesRoutes } from "./api/releases.ts";
314315 import { hookRoutes } from "./api/hooks.ts";
315316 import { agentRoutes } from "./api/agents.ts";
316317 import { boardRoutes } from "./api/boards.ts";
@@ -3169,6 +3170,7 @@ export function apiRoutes(router: Router<Handler>): void {
31693170 });
31703171
31713172 forgeRoutes(router);
3173 releasesRoutes(router);
31723174
31733175 router.add("GET", "/api/v1/apps", (ctx) => {
31743176 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
src/http/api/releases.ts added +23
@@ -0,0 +1,23 @@
1 /**
2 * `GET /api/v1/releases`: the kept releases for whoever may read /releases,
3 * and `?shipped=true` what went live, for anyone — MCP `releases` over HTTP.
4 * The answers are `release/answer.ts`'s.
5 */
6
7 import { releasesAnswer, shippedAnswer } from "../../release/answer.ts";
8 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
9 import type { Handler } from "../context.ts";
10 import { apiError, json } from "../respond.ts";
11 import type { Router } from "../router.ts";
12
13 export function releasesRoutes(router: Router<Handler>): void {
14 router.add("GET", "/api/v1/releases", async (ctx) => {
15 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
16 const shipped = ctx.url.searchParams.get("shipped");
17 if (shipped !== null && shipped !== "true")
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));
20 const answer = releasesAnswer(ctx.app, ctx.auth.user);
21 return answer.ok ? json(answer.value) : apiError(answer.error, 403, answer.message);
22 });
23 }
src/http/mcp/tools/releases.ts +4 −26
@@ -1,8 +1,4 @@
1 import { getRepoById } from "../../../core/repos.ts";
2 import { listReleases, releaseName } from "../../../release/record.ts";
3 import { BOOTED, mayRead } from "../../../release/gate.ts";
4 import { readLandings } from "../../../release/landings.ts";
5 import { running } from "../../../kernel/version.ts";
1 import { releasesAnswer, shippedAnswer } from "../../../release/answer.ts";
62 import { bad, good, type Tool } from "../shared.ts";
73
84 export const tool: Tool = {
@@ -12,27 +8,9 @@ export const tool: Tool = {
128 inputSchema: { type: "object", properties: { shipped: { type: "boolean" } } },
139 writes: false,
1410 async run(ctx, user, args) {
15 if (args["shipped"] === true) {
16 const { version, commit, live, onItsWay } = await readLandings(ctx.app);
17 return good({ version, commit, live, onItsWay });
18 }
11 if (args["shipped"] === true) return good(await shippedAnswer(ctx.app));
1912 // The page's own gate: its refusal, in words.
20 if (!mayRead(ctx.app, user))
21 return bad("forbidden", "the release history is for the forge's administrators and its repository's readers");
22 const live = running();
23 return good({
24 items: listReleases(ctx.app.db).map((r) => {
25 const isLive = live.commit !== null && r.sha === live.commit;
26 const repo = getRepoById(ctx.app.db, r.repoId);
27 return {
28 sha: releaseName(r),
29 version: isLive ? live.version : null,
30 pull: r.pullNumber === null ? null : { repo: repo === undefined ? null : `${repo.ownerName}/${repo.name}`, number: r.pullNumber },
31 landed: r.createdAt,
32 ok: r.ok,
33 ...(isLive ? { live: true, since: BOOTED } : {}),
34 };
35 }),
36 });
13 const answer = releasesAnswer(ctx.app, user);
14 return answer.ok ? good(answer.value) : bad(answer.error, answer.message);
3715 },
3816 };
src/release/answer.ts added +48
@@ -0,0 +1,48 @@
1 /**
2 * What the forge says about its releases, as plain data: what went live and
3 * what is on its way, and the kept list. MCP `releases` and
4 * `GET /api/v1/releases` both answer from here.
5 */
6
7 import type { App } from "../app.ts";
8 import { getRepoById } from "../core/repos.ts";
9 import type { User } from "../core/users.ts";
10 import { running } from "../kernel/version.ts";
11 import { BOOTED, mayRead } from "./gate.ts";
12 import { readLandings } from "./landings.ts";
13 import { listReleases, releaseName } from "./record.ts";
14
15 export const RELEASES_REFUSAL = "the release history is for the forge's administrators and its repository's readers";
16
17 export type ReleasesAnswer =
18 | { ok: true; value: unknown }
19 | { ok: false; error: "forbidden"; message: string };
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 };
25 }
26
27 /** The kept releases newest first, for whoever may read /releases; others get its refusal. */
28 export function releasesAnswer(app: App, user: User | undefined): ReleasesAnswer {
29 if (!mayRead(app, user)) return { ok: false, error: "forbidden", message: RELEASES_REFUSAL };
30 const live = running();
31 return {
32 ok: true,
33 value: {
34 items: listReleases(app.db).map((r) => {
35 const isLive = live.commit !== null && r.sha === live.commit;
36 const repo = getRepoById(app.db, r.repoId);
37 return {
38 sha: releaseName(r),
39 version: isLive ? live.version : null,
40 pull: r.pullNumber === null ? null : { repo: repo === undefined ? null : `${repo.ownerName}/${repo.name}`, number: r.pullNumber },
41 landed: r.createdAt,
42 ok: r.ok,
43 ...(isLive ? { live: true, since: BOOTED } : {}),
44 };
45 }),
46 },
47 };
48 }
test/api-releases.test.ts added +95
@@ -0,0 +1,95 @@
1 /** `GET /api/v1/releases` is MCP `releases` over HTTP: the same rows, the same gate, `?shipped=true` open to anyone. */
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 { serve, type Servers } from "../src/http/server.ts";
11 import { recordRelease } from "../src/release/record.ts";
12
13 const LIVE = "a".repeat(40);
14
15 describe("GET /api/v1/releases", () => {
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19 let admin: string;
20 let bob: string;
21 const saved = process.env["FORGE_COMMIT"];
22 const get = (query: string, token?: string) =>
23 fetch(`${servers.url}/api/v1/releases${query}`, token ? { headers: { authorization: `Bearer ${token}` } } : {});
24
25 beforeAll(async () => {
26 process.env["FORGE_COMMIT"] = LIVE;
27 dataDir = await mkdtemp(join(tmpdir(), "forge-api-releases-"));
28 app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" });
29 servers = await serve(app);
30 const mk = async (username: string, first = false) => {
31 const u = await createUser(app.db, {
32 origin: "seed", trusted: true, username,
33 email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first,
34 });
35 if (!u.ok) throw new Error(u.error);
36 return u.value;
37 };
38 const alice = await mk("alice", true);
39 const b = await mk("bob");
40 admin = createToken(app.db, alice.id, "a", "admin").token;
41 bob = createToken(app.db, b.id, "b", "admin").token;
42 const made = await createRepo(app, alice, { name: "forge", visibility: "public" });
43 if (!made.ok) throw new Error(String(made.error));
44 recordRelease(app.db, { sha: LIVE, repoId: made.value.id, pullNumber: 2, ok: true, line: "ok" });
45 }, 30_000);
46
47 afterAll(async () => {
48 if (saved === undefined) delete process.env["FORGE_COMMIT"];
49 else process.env["FORGE_COMMIT"] = saved;
50 servers.stop();
51 app.close();
52 await rm(dataDir, { recursive: true, force: true });
53 });
54
55 test("?shipped=true answers anyone, no token", async () => {
56 const res = await get("?shipped=true");
57 expect(res.status).toBe(200);
58 const body: any = await res.json();
59 expect(Array.isArray(body.live)).toBe(true);
60 expect(Array.isArray(body.onItsWay)).toBe(true);
61 expect(body.commit).toBe(LIVE);
62 });
63
64 test("an administrator gets the kept releases, the live one marked", async () => {
65 const res = await get("", admin);
66 expect(res.status).toBe(200);
67 const { items }: any = await res.json();
68 expect(items.map((i: any) => i.sha)).toEqual(["a".repeat(12)]);
69 expect(items[0].live).toBe(true);
70 expect(items[0].pull).toEqual({ repo: "alice/forge", number: 2 });
71 });
72
73 test("a stranger and an anonymous caller are refused in words", async () => {
74 for (const token of [bob, undefined]) {
75 const res = await get("", token);
76 expect(res.status).toBe(403);
77 const body: any = await res.json();
78 expect(body.error).toBe("forbidden");
79 expect(body.message).toContain("release history");
80 }
81 });
82
83 test("any other shipped value is 422 invalid_shipped", async () => {
84 for (const v of ["maybe", "false", ""]) {
85 const res = await get(`?shipped=${v}`, admin);
86 expect(res.status).toBe(422);
87 expect(((await res.json()) as any).error).toBe("invalid_shipped");
88 }
89 });
90
91 test("the OpenAPI document lists the route", async () => {
92 const doc = (await (await fetch(`${servers.url}/api/v1/openapi.json`)).json()) as any;
93 expect(doc.paths["/api/v1/releases"].get).toBeDefined();
94 });
95 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs