Public: anyone can read this; the crew can change it.
The OpenAPI document describes an issue's dependencies, a crew seat, a secret and the front page #5267
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5242-the-openapi-document-describes-an-issue → main 1 commit fork
2 files
changed +118
−1
src/contract/openapi.ts +46 −1
| @@ -244,6 +244,18 @@ export const ROUTES: readonly Route[] = [ | ||
| 244 | 244 | query: { ref: "string" }, |
| 245 | 245 | refusals: { not_found: "the scope's owner is unknown", invalid_scope: "the scope is not one the forge keeps secrets for" }, |
| 246 | 246 | }, |
| 247 | { | |
| 248 | method: "put", | |
| 249 | path: "/api/v1/secrets/{scope}/{name}", | |
| 250 | summary: "Set a secret by name (admin scope, a site admin's); the value is write-only, the read never returns it; `ref` names an org or `owner/name`; answers the name and timestamps, never the value", | |
| 251 | auth: true, | |
| 252 | body: { value: "string", ref: "string" }, | |
| 253 | refusals: { | |
| 254 | invalid_name: "`name` is not an acceptable secret name (422)", | |
| 255 | invalid_value: "`value` is not acceptable (422)", | |
| 256 | invalid_scope: "the scope is not one the forge keeps secrets for (422)", | |
| 257 | }, | |
| 258 | }, | |
| 247 | 259 | { |
| 248 | 260 | method: "delete", |
| 249 | 261 | path: "/api/v1/secrets/{scope}/{name}", |
| @@ -330,6 +342,17 @@ export const ROUTES: readonly Route[] = [ | ||
| 330 | 342 | body: { title: "string", head: "string", headRepo: "string" }, |
| 331 | 343 | }, |
| 332 | 344 | { method: "get", path: `${REPO}/crew`, summary: "List the repository's crew; answers { items, next_cursor } (names and roles for every reader, brief, model and budget for writers)", auth: false }, |
| 345 | { | |
| 346 | method: "put", | |
| 347 | path: `${REPO}/crew/{username}`, | |
| 348 | summary: "Put an agent on the repository's crew (admin scope, a repository admin's); answers the agent", | |
| 349 | auth: true, | |
| 350 | refusals: { | |
| 351 | no_such_agent: "`username` names no one (422)", | |
| 352 | not_an_agent: "`username` is a person, not an agent (422)", | |
| 353 | unverified: "the caller's email is unproven (403)", | |
| 354 | }, | |
| 355 | }, | |
| 333 | 356 | { |
| 334 | 357 | method: "delete", |
| 335 | 358 | path: `${REPO}/crew/{username}`, |
| @@ -710,12 +733,26 @@ export const ROUTES: readonly Route[] = [ | ||
| 710 | 733 | refusals: { invalid_days: "days is not a whole number in range" }, |
| 711 | 734 | }, |
| 712 | 735 | { method: "get", path: "/api/v1/here", summary: "The manifest of what you can do at `path` (default /), given who you are: { here, you, actions, mcp, docs }; never cached", auth: false, query: { path: "string" }, refusals: { not_found: "404: the path names no page you can read" } }, |
| 736 | { | |
| 737 | method: "get", | |
| 738 | path: `${REPO}/issues/{number}/dependencies`, | |
| 739 | summary: "What the issue waits for and what waits for it: { blockedBy, blocks }, each a list of { number, title, state, closeReason? }; issues the caller cannot see are left out", | |
| 740 | auth: false, | |
| 741 | }, | |
| 713 | 742 | { |
| 714 | 743 | method: "post", |
| 715 | 744 | path: `${REPO}/issues/{number}/dependencies`, |
| 716 | summary: "Make an issue wait for another", | |
| 745 | summary: "Make an issue wait for another (rw scope); answers 201 with { issue, blockedBy }", | |
| 717 | 746 | auth: true, |
| 747 | status: 201, | |
| 718 | 748 | body: { blockedBy: "integer" }, |
| 749 | refusals: { | |
| 750 | no_such_issue: "`blockedBy` names no issue in this repository (422)", | |
| 751 | self: "an issue cannot wait for itself (422)", | |
| 752 | cycle: "that would make a loop (422)", | |
| 753 | cross_repo: "dependencies stay inside one repository (422)", | |
| 754 | not_an_issue: "only issues can depend on issues (422)", | |
| 755 | }, | |
| 719 | 756 | }, |
| 720 | 757 | { |
| 721 | 758 | method: "delete", |
| @@ -789,6 +826,14 @@ export const ROUTES: readonly Route[] = [ | ||
| 789 | 826 | }, |
| 790 | 827 | { method: "post", path: "/api/v1/fleet/ops/{id}/undo", summary: "Undo a fleet operation (admin scope); answers 201 with the undoing operation", auth: true, status: 201 }, |
| 791 | 828 | { method: "get", path: "/api/v1/front", summary: "The front page a stranger is shown: { featured, apps, total }, each entry { repo, description, url, state, picture, pinned, score, signals }; a site admin also gets `picks`", auth: false }, |
| 829 | { | |
| 830 | method: "put", | |
| 831 | path: "/api/v1/front", | |
| 832 | summary: "Set the front page's picks (a site admin's, rw scope): `pinned` and `hidden` are arrays of owner/name, `weights` an object of forks, ships, use, fresh; answers the picks", | |
| 833 | auth: true, | |
| 834 | body: { pinned: "array", hidden: "array", weights: "object" }, | |
| 835 | refusals: { invalid: "the picks are malformed or name no repository (422)", forbidden: "the front page is a site admin's to set (403)" }, | |
| 836 | }, | |
| 792 | 837 | { |
| 793 | 838 | method: "get", |
| 794 | 839 | path: "/api/v1/health", |
test/openapi-oa-5242.test.ts added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | /** The OpenAPI document describes issue dependencies, a crew seat, a secret and the front page. */ | |
| 2 | import { 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 } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve } from "../src/http/server.ts"; | |
| 10 | import { tokenFor } from "./helpers/tokens.ts"; | |
| 11 | import { openapi } from "../src/contract/openapi.ts"; | |
| 12 | ||
| 13 | const R = "/api/v1/repos/{owner}/{name}"; | |
| 14 | const WANT: readonly (readonly [string, string, string])[] = [ | |
| 15 | [`${R}/issues/{number}/dependencies`, "get", ""], | |
| 16 | [`${R}/issues/{number}/dependencies`, "post", "no_such_issue"], | |
| 17 | [`${R}/crew/{username}`, "put", "no_such_agent"], | |
| 18 | [`${R}/crew/{username}`, "delete", "no_such_agent"], | |
| 19 | ["/api/v1/secrets/{scope}", "get", "invalid_scope"], | |
| 20 | ["/api/v1/secrets/{scope}/{name}", "put", "invalid_name"], | |
| 21 | ["/api/v1/front", "get", ""], | |
| 22 | ["/api/v1/front", "put", "forbidden"], | |
| 23 | ]; | |
| 24 | ||
| 25 | test("each path and method is served, with a refusal code the route answers", () => { | |
| 26 | const doc = openapi("https://f.test", "1") as any; | |
| 27 | for (const [path, m, code] of WANT) { | |
| 28 | const op = doc.paths[path]?.[m]; | |
| 29 | expect(op, `${m} ${path}`).toBeDefined(); | |
| 30 | if (code) expect(JSON.stringify(op), `${m} ${path}`).toContain(`\`${code}\``); | |
| 31 | } | |
| 32 | expect(doc.paths[`${R}/issues/{number}/dependencies`].post.responses["201"]).toBeDefined(); | |
| 33 | expect(doc.paths["/api/v1/secrets/{scope}/{name}"].put.summary).toContain("write-only"); | |
| 34 | expect(doc.paths["/api/v1/secrets/{scope}"].get.summary).toContain("never a value"); | |
| 35 | }); | |
| 36 | ||
| 37 | test("one refusal per row is what the route answers", async () => { | |
| 38 | const dir = await mkdtemp(join(tmpdir(), "forge-oa-5242-")); | |
| 39 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 40 | const servers = await serve(app); | |
| 41 | try { | |
| 42 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 43 | if (!a.ok) throw new Error(a.error); | |
| 44 | const made = await createRepo(app, a.value, { name: "site", visibility: "public" }); | |
| 45 | if (!made.ok) throw new Error(String(made.error)); | |
| 46 | const token = await tokenFor(app, "alice", "admin"); | |
| 47 | const call = async (method: string, path: string, body?: unknown) => { | |
| 48 | const res = await fetch(`${servers.url}${path}`, { | |
| 49 | method, | |
| 50 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 51 | body: body === undefined ? undefined : JSON.stringify(body), | |
| 52 | }); | |
| 53 | return { status: res.status, body: (await res.json()) as any }; | |
| 54 | }; | |
| 55 | const repo = "/api/v1/repos/alice/site"; | |
| 56 | const issue = await call("POST", `${repo}/issues`, { title: "one" }); | |
| 57 | expect(issue.status).toBe(201); | |
| 58 | const n = issue.body.number; | |
| 59 | expect((await call("GET", `${repo}/issues/${n}/dependencies`)).body).toEqual({ blockedBy: [], blocks: [] }); | |
| 60 | expect(await call("POST", `${repo}/issues/${n}/dependencies`, { blockedBy: 99 })).toMatchObject({ status: 422, body: { error: "no_such_issue" } }); | |
| 61 | expect(await call("POST", `${repo}/issues/${n}/dependencies`, { blockedBy: n })).toMatchObject({ status: 422, body: { error: "self" } }); | |
| 62 | expect(await call("PUT", `${repo}/crew/nobody`)).toMatchObject({ status: 422, body: { error: "no_such_agent" } }); | |
| 63 | expect(await call("PUT", "/api/v1/secrets/forge/bad", { value: "v" })).toMatchObject({ status: 422, body: { error: "invalid_name" } }); | |
| 64 | expect(await call("GET", "/api/v1/secrets/nope")).toMatchObject({ status: 422, body: { error: "invalid_scope" } }); | |
| 65 | expect((await call("GET", "/api/v1/front")).status).toBe(200); | |
| 66 | expect(await call("PUT", "/api/v1/front", { pinned: "x" })).toMatchObject({ status: 422, body: { error: "invalid" } }); | |
| 67 | } finally { | |
| 68 | servers.stop(); | |
| 69 | app.close(); | |
| 70 | await rm(dir, { recursive: true, force: true }); | |
| 71 | } | |
| 72 | }); |
new above
offline