Public: anyone can read this; the crew can change it.
The OpenAPI document describes the map, plan and issue-dependency routes #3291
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3270-the-openapi-document-describes-the-map-p → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +54
−5
docs/MCP.md +3 −1
| @@ -181,7 +181,9 @@ answers | ||
| 181 | 181 | and `text`. Empty words send nothing. `asks` rows carry `repo` |
| 182 | 182 | (`owner/name`) beside `number` for this. |
| 183 | 183 | - **Contract.** `GET /contract/openapi.json` is OpenAPI 3.1 for the routes |
| 184 | the manifest (`here`) names, plus `POST /mcp`. `test/openapi.test.ts` fails | |
| 184 | the manifest (`here`) names, plus `POST /mcp`. It also describes planning: `GET …/map`, `POST …/plan`, | |
| 185 | `POST …/issues/{number}/dependencies` and | |
| 186 | `DELETE …/issues/{number}/dependencies/{blocker}`. `test/openapi.test.ts` fails | |
| 185 | 187 | when the manifest names a route it lacks. |
| 186 | 188 | - **Where a tell came from.** `tell` takes `context`: `page` (http/https), |
| 187 | 189 | `run`, `commit` (hex) and `said` (the person's words). Malformed context is |
src/contract/openapi.ts +24 −3
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | interface Route { |
| 11 | readonly method: "get" | "post" | "patch"; | |
| 11 | readonly method: "get" | "post" | "patch" | "delete"; | |
| 12 | 12 | readonly path: string; |
| 13 | 13 | readonly summary: string; |
| 14 | 14 | /** Whether it needs a token; reads of public things do not. */ |
| @@ -61,6 +61,27 @@ export const ROUTES: readonly Route[] = [ | ||
| 61 | 61 | { method: "post", path: `${REPO}/pulls/{number}/merge`, summary: "Merge a pull (a person's)", auth: true }, |
| 62 | 62 | { method: "patch", path: REPO, summary: "Change a repository's settings", auth: true }, |
| 63 | 63 | { method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false }, |
| 64 | { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars", auth: false }, | |
| 65 | { | |
| 66 | method: "post", | |
| 67 | path: `${REPO}/plan`, | |
| 68 | summary: "Open a plan of issues and their dependencies, all or nothing", | |
| 69 | auth: true, | |
| 70 | body: { issues: "array" }, | |
| 71 | }, | |
| 72 | { | |
| 73 | method: "post", | |
| 74 | path: `${REPO}/issues/{number}/dependencies`, | |
| 75 | summary: "Make an issue wait for another", | |
| 76 | auth: true, | |
| 77 | body: { blockedBy: "integer" }, | |
| 78 | }, | |
| 79 | { | |
| 80 | method: "delete", | |
| 81 | path: `${REPO}/issues/{number}/dependencies/{blocker}`, | |
| 82 | summary: "Stop an issue waiting for another", | |
| 83 | auth: true, | |
| 84 | }, | |
| 64 | 85 | { |
| 65 | 86 | method: "post", |
| 66 | 87 | path: "/mcp", |
| @@ -70,7 +91,7 @@ export const ROUTES: readonly Route[] = [ | ||
| 70 | 91 | }, |
| 71 | 92 | ]; |
| 72 | 93 | |
| 73 | const schema = (t: string) => (t === "object" ? { type: "object" } : { type: t }); | |
| 94 | const schema = (t: string) => (t === "object" ? { type: "object" } : t === "array" ? { type: "array", items: { type: "object" } } : { type: t }); | |
| 74 | 95 | |
| 75 | 96 | /** The document, for the forge served at `origin`. */ |
| 76 | 97 | export function openapi(origin: string, version: string): Record<string, unknown> { |
| @@ -80,7 +101,7 @@ export function openapi(origin: string, version: string): Record<string, unknown | ||
| 80 | 101 | name: m[1], |
| 81 | 102 | in: "path", |
| 82 | 103 | required: true, |
| 83 | schema: { type: m[1] === "number" ? "integer" : "string" }, | |
| 104 | schema: { type: m[1] === "number" || m[1] === "blocker" ? "integer" : "string" }, | |
| 84 | 105 | })); |
| 85 | 106 | (paths[r.path] ??= {})[r.method] = { |
| 86 | 107 | operationId: `${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}`, |
test/openapi.test.ts +27 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** The OpenAPI document names every route the manifest names. */ |
| 2 | 2 | import { expect, test } from "bun:test"; |
| 3 | 3 | import { readFileSync } from "node:fs"; |
| 4 | import { openapi } from "../src/contract/openapi.ts"; | |
| 4 | import { openapi, ROUTES } from "../src/contract/openapi.ts"; | |
| 5 | 5 | |
| 6 | 6 | test("every http route the manifest names is described", () => { |
| 7 | 7 | const src = readFileSync("src/actions/here.ts", "utf8"); |
| @@ -20,3 +20,29 @@ test("every http route the manifest names is described", () => { | ||
| 20 | 20 | expect(doc.openapi).toBe("3.1.0"); |
| 21 | 21 | expect(doc.paths["/api/v1/repos/{owner}/{name}/issues"].post.security).toBeDefined(); |
| 22 | 22 | }); |
| 23 | ||
| 24 | test("every described route is registered in the router", () => { | |
| 25 | const src = ["src/http/server.ts", "src/http/api.ts", "src/http/mcp.ts"] | |
| 26 | .map((f) => readFileSync(f, "utf8")) | |
| 27 | .join("\n"); | |
| 28 | const registered = new Set( | |
| 29 | [...src.matchAll(/"(GET|POST|PATCH|DELETE|PUT)",\s*"([^"]+)"/g)].map((m) => `${m[1]} ${m[2]}`), | |
| 30 | ); | |
| 31 | const shape = (p: string) => p.replace(/\{\w+\}|:\w+/g, ":"); | |
| 32 | const shapes = new Set([...registered].map((r) => shape(r))); | |
| 33 | for (const r of ROUTES) { | |
| 34 | const want = shape(`${r.method.toUpperCase()} ${r.path}`); | |
| 35 | const literal = shape(`${r.method.toUpperCase()} ${r.path.replace("production", ":")}`); | |
| 36 | expect(shapes.has(want) || shapes.has(literal), `${r.method} ${r.path}`).toBe(true); | |
| 37 | } | |
| 38 | }); | |
| 39 | ||
| 40 | test("the map, plan and dependency routes are described", () => { | |
| 41 | const doc = openapi("https://f.test", "1") as any; | |
| 42 | const R = "/api/v1/repos/{owner}/{name}"; | |
| 43 | expect(doc.paths[`${R}/map`].get.security).toBeUndefined(); | |
| 44 | expect(doc.paths[`${R}/plan`].post.requestBody.content["application/json"].schema.properties.issues.type).toBe("array"); | |
| 45 | expect(doc.paths[`${R}/issues/{number}/dependencies`].post.security).toBeDefined(); | |
| 46 | const del = doc.paths[`${R}/issues/{number}/dependencies/{blocker}`].delete; | |
| 47 | expect(del.parameters.find((p: any) => p.name === "blocker").schema.type).toBe("integer"); | |
| 48 | }); |
new above
offline
This route is documented with one generic success response, but the router answers 201 for an opened plan and 202 for one stored awaiting a nod. Add both statuses to the document.