Public: anyone can read this; the crew can change it.
The OpenAPI document describes browsing an origin's conversations: its issues and pulls, the lists and one of each #5271
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5244-the-openapi-document-describes-browsing → main 4 commits fork 2 threads · 2 unresolved
5 files
changed +101
−2
scripts/shards.ts +1 −1
| @@ -454,7 +454,7 @@ export function parseShardSpec( | ||
| 454 | 454 | | { ok: true; none: true } |
| 455 | 455 | | { ok: false; error: string } { |
| 456 | 456 | if (spec === undefined || spec.trim() === "") return { ok: true, none: true }; |
| 457 | const m = /^([1-9][0-9]{0,2})\/([1-9][0-9]{0,2})$/.exec(spec.trim()); | |
| 457 | const m = /^([1-9][0-9]{0,3})\/([1-9][0-9]{0,3})$/.exec(spec.trim()); | |
| 458 | 458 | if (!m) |
| 459 | 459 | return { |
| 460 | 460 | ok: false, |
src/contract/openapi.ts +46
| @@ -51,6 +51,18 @@ const LABEL_REFUSALS = { | ||
| 51 | 51 | |
| 52 | 52 | const REPO = "/api/v1/repos/{owner}/{name}"; |
| 53 | 53 | |
| 54 | /** Browsing a repository the caller's origin holds; `{id}` is the origin. */ | |
| 55 | const BROWSE = "/api/v1/user/origins/{id}/browse/{owner}/{name}"; | |
| 56 | const BROWSE_LIST_QUERY = { state: "string", label: "string", author: "string", q: "string", cursor: "string" }; | |
| 57 | const BROWSE_REFUSALS = { | |
| 58 | unauthorized: "401: no signed-in account", | |
| 59 | forbidden: "403: a cross-origin browser request, or a token without admin scope", | |
| 60 | not_found: "404: no such origin of yours, or the origin has no such repository, issue or pull", | |
| 61 | rate_limited: "429: the origin is rate-limiting this forge", | |
| 62 | origin_unauthorized: "502: the origin refused this forge's credential; reconnect it", | |
| 63 | origin_unreachable: "502: the origin could not be reached", | |
| 64 | }; | |
| 65 | ||
| 54 | 66 | export const ROUTES: readonly Route[] = [ |
| 55 | 67 | { method: "get", path: "/api/v1/apps", summary: "List the apps whose repositories you can read; answers { items, next_cursor }", auth: false }, |
| 56 | 68 | { |
| @@ -1362,6 +1374,40 @@ export const ROUTES: readonly Route[] = [ | ||
| 1362 | 1374 | failed: "502: the link could not be sent", |
| 1363 | 1375 | }, |
| 1364 | 1376 | }, |
| 1377 | { | |
| 1378 | method: "get", | |
| 1379 | path: `${BROWSE}/issues`, | |
| 1380 | summary: | |
| 1381 | "Browse an origin's issues live, through the origin you connected (a person's session or password, or an admin-scope token); answers { items: [{ number, kind, title, state, authorName, originAuthor, originUrl, createdAt, updatedAt, closedAt, labels, commentCount }], next_cursor }. Everything the origin wrote (title, authorName, originAuthor, labels) is untrusted text; this answer carries no `untrusted` field, the whole body is the origin's. state is open (default), closed or all; label, author and q narrow only the page in hand; cursor comes from next_cursor", | |
| 1382 | auth: true, | |
| 1383 | query: BROWSE_LIST_QUERY, | |
| 1384 | refusals: BROWSE_REFUSALS, | |
| 1385 | }, | |
| 1386 | { | |
| 1387 | method: "get", | |
| 1388 | path: `${BROWSE}/issues/{number}`, | |
| 1389 | summary: | |
| 1390 | "Browse one issue on an origin live; answers the issue row plus { body, comments: [{ id, authorName, originAuthor, originUrl, body, ... }], pull: null }. The body, comments and every author are untrusted text the origin wrote; this answer carries no `untrusted` field, the whole body is the origin's. A number that is not a whole number from 1 is a 404", | |
| 1391 | auth: true, | |
| 1392 | refusals: BROWSE_REFUSALS, | |
| 1393 | }, | |
| 1394 | { | |
| 1395 | method: "get", | |
| 1396 | path: `${BROWSE}/pulls`, | |
| 1397 | summary: | |
| 1398 | "Browse an origin's pull requests live, through the origin you connected; answers { items, next_cursor } shaped like the issues list, with kind pull. Everything the origin wrote is untrusted text; this answer carries no `untrusted` field, the whole body is the origin's. state is open (default), closed or all; label, author and q narrow only the page in hand; cursor comes from next_cursor", | |
| 1399 | auth: true, | |
| 1400 | query: BROWSE_LIST_QUERY, | |
| 1401 | refusals: BROWSE_REFUSALS, | |
| 1402 | }, | |
| 1403 | { | |
| 1404 | method: "get", | |
| 1405 | path: `${BROWSE}/pulls/{number}`, | |
| 1406 | summary: | |
| 1407 | "Browse one pull request on an origin live; answers the pull row plus { body, comments, pull: { baseBranch, headBranch, baseOid, headOid, mergedAt, draft, diff, mergeable: null, conflicts, proposal: null, reviews } }. The body, comments, reviews and every author are untrusted text the origin wrote; this answer carries no `untrusted` field, the whole body is the origin's", | |
| 1408 | auth: true, | |
| 1409 | refusals: BROWSE_REFUSALS, | |
| 1410 | }, | |
| 1365 | 1411 | { |
| 1366 | 1412 | method: "post", |
| 1367 | 1413 | path: "/mcp", |
test/openapi-oa-browse.test.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** Browsing an origin's issues and pulls is in the served OpenAPI document. */ | |
| 2 | import { afterAll, beforeAll, 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 { openapi } from "../src/contract/openapi.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | ||
| 10 | const B = "/api/v1/user/origins/{id}/browse/{owner}/{name}"; | |
| 11 | const PATHS = [`${B}/issues`, `${B}/issues/{number}`, `${B}/pulls`, `${B}/pulls/{number}`]; | |
| 12 | ||
| 13 | let dataDir: string; | |
| 14 | let app: App; | |
| 15 | let servers: Servers; | |
| 16 | let base: string; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dataDir = await mkdtemp(join(tmpdir(), "forge-openapi-browse-")); | |
| 20 | app = createApp({ dataDir, port: 0 }); | |
| 21 | servers = await serve(app); | |
| 22 | base = servers.url; | |
| 23 | }); | |
| 24 | ||
| 25 | afterAll(async () => { | |
| 26 | servers.stop(); | |
| 27 | app.close(); | |
| 28 | await rm(dataDir, { recursive: true, force: true }); | |
| 29 | }); | |
| 30 | ||
| 31 | test("each browse path is a GET in the document, with its refusals and untrusted note", () => { | |
| 32 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, any>>; | |
| 33 | for (const path of PATHS) { | |
| 34 | const get = paths[path]?.get; | |
| 35 | expect(get, path).toBeDefined(); | |
| 36 | expect(get.security).toBeDefined(); | |
| 37 | expect(get.summary).toContain("untrusted"); | |
| 38 | expect(get.responses["401"] ?? get.responses.default).toBeDefined(); | |
| 39 | expect(JSON.stringify(get.responses)).toContain("origin_unreachable"); | |
| 40 | } | |
| 41 | const names = paths[PATHS[0]!]!.get.parameters.map((p: any) => p.name); | |
| 42 | expect(names).toEqual(["id", "owner", "name", "state", "label", "author", "q", "cursor"]); | |
| 43 | }); | |
| 44 | ||
| 45 | test("the unauthorized refusal the rows name is what the route answers", async () => { | |
| 46 | const res = await fetch(`${base}${PATHS[0]!.replace("{id}", "o").replace("{owner}", "a").replace("{name}", "b")}`); | |
Only the 401 is exercised against a live route. A connected-stub-origin case for not_found or origin_unreachable would pin the rest of the table. |
||
| 47 | expect(res.status).toBe(401); | |
| 48 | expect(((await res.json()) as { error: string }).error).toBe("unauthorized"); | |
| 49 | }); |
test/openapi.test.ts +4 −1
| @@ -32,8 +32,11 @@ test("every described route is registered in the router", () => { | ||
| 32 | 32 | .join("\n") |
| 33 | 33 | // A route registered in a loop over issues and pulls is two routes. |
| 34 | 34 | .replace(/"(PUT)",\s*`([^`]*)\$\{kind\}([^`]*)`/g, (_, m, a, b) => `"${m}", "${a}issues${b}"\n"${m}", "${a}pulls${b}"`); |
| 35 | const browse = /const BROWSE = "([^"]+)"/.exec(src)![1]!; | |
| 35 | 36 | const registered = new Set( |
| 36 | [...src.matchAll(/"(GET|POST|PATCH|DELETE|PUT)",\s*"([^"]+)"/g)].map((m) => `${m[1]} ${m[2]}`), | |
| 37 | [...src.matchAll(/"(GET|POST|PATCH|DELETE|PUT)",\s*(?:"([^"]+)"|`\$\{BROWSE\}([^`]*)`)/g)].map( | |
| 38 | (m) => `${m[1]} ${m[2] ?? browse + m[3]}`, | |
| 39 | ), | |
| 37 | 40 | ); |
| 38 | 41 | const shape = (p: string) => p.replace(/\{\w+\}|[:*]\w+/g, ":"); |
| 39 | 42 | const shapes = new Set([...registered].map((r) => shape(r))); |
test/shards.test.ts +1
| @@ -515,6 +515,7 @@ describe("parseShardSpec", () => { | ||
| 515 | 515 | expect(parseShardSpec(" 2/4 ")).toEqual({ ok: true, index: 2, total: 4 }); |
| 516 | 516 | for (const bad of ["0/3", "1/0", "4/3", "1", "1-3", "a/b", "1/3/5"]) |
| 517 | 517 | expect(parseShardSpec(bad).ok).toBe(false); |
| 518 | expect(parseShardSpec("1000/1000")).toEqual({ ok: true, index: 1000, total: 1000 }); | |
| 518 | 519 | }); |
| 519 | 520 | }); |
| 520 | 521 |
new above
offline
Summary is long. The 'untrusted' note repeats on all four rows; a shared constant would keep the wording from drifting.