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

The OpenAPI document describes the map, plan and issue-dependency routes #3291

Merged opened this

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

Merged

scout/forge:scout/3270-the-openapi-document-describes-the-map-p → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/3270-the-openapi-document-describes-the-map-p was merged into main as e35958f.

Review

Sign in to review this change.
3 files changed +54 −5
docs/MCP.md +3 −1
@@ -181,7 +181,9 @@ answers
181181 and `text`. Empty words send nothing. `asks` rows carry `repo`
182182 (`owner/name`) beside `number` for this.
183183 - **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
185187 when the manifest names a route it lacks.
186188 - **Where a tell came from.** `tell` takes `context`: `page` (http/https),
187189 `run`, `commit` (hex) and `said` (the person's words). Malformed context is
src/contract/openapi.ts +24 −3
@@ -8,7 +8,7 @@
88 */
99
1010 interface Route {
11 readonly method: "get" | "post" | "patch";
11 readonly method: "get" | "post" | "patch" | "delete";
1212 readonly path: string;
1313 readonly summary: string;
1414 /** Whether it needs a token; reads of public things do not. */
@@ -61,6 +61,27 @@ export const ROUTES: readonly Route[] = [
6161 { method: "post", path: `${REPO}/pulls/{number}/merge`, summary: "Merge a pull (a person's)", auth: true },
6262 { method: "patch", path: REPO, summary: "Change a repository's settings", auth: true },
6363 { 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 },

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.

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 },
6485 {
6586 method: "post",
6687 path: "/mcp",
@@ -70,7 +91,7 @@ export const ROUTES: readonly Route[] = [
7091 },
7192 ];
7293
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 });
7495
7596 /** The document, for the forge served at `origin`. */
7697 export function openapi(origin: string, version: string): Record<string, unknown> {
@@ -80,7 +101,7 @@ export function openapi(origin: string, version: string): Record<string, unknown
80101 name: m[1],
81102 in: "path",
82103 required: true,
83 schema: { type: m[1] === "number" ? "integer" : "string" },
104 schema: { type: m[1] === "number" || m[1] === "blocker" ? "integer" : "string" },
84105 }));
85106 (paths[r.path] ??= {})[r.method] = {
86107 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 @@
11 /** The OpenAPI document names every route the manifest names. */
22 import { expect, test } from "bun:test";
33 import { readFileSync } from "node:fs";
4 import { openapi } from "../src/contract/openapi.ts";
4 import { openapi, ROUTES } from "../src/contract/openapi.ts";
55
66 test("every http route the manifest names is described", () => {
77 const src = readFileSync("src/actions/here.ts", "utf8");
@@ -20,3 +20,29 @@ test("every http route the manifest names is described", () => {
2020 expect(doc.openapi).toBe("3.1.0");
2121 expect(doc.paths["/api/v1/repos/{owner}/{name}/issues"].post.security).toBeDefined();
2222 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs