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

Merged opened this

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

Merged

scout/forge:scout/5244-the-openapi-document-describes-browsing → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/5244-the-openapi-document-describes-browsing was merged into main as 06d91d3.

Review

Sign in to review this change.
5 files changed +101 −2
scripts/shards.ts +1 −1
@@ -454,7 +454,7 @@ export function parseShardSpec(
454454 | { ok: true; none: true }
455455 | { ok: false; error: string } {
456456 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());
458458 if (!m)
459459 return {
460460 ok: false,
src/contract/openapi.ts +46
@@ -51,6 +51,18 @@ const LABEL_REFUSALS = {
5151
5252 const REPO = "/api/v1/repos/{owner}/{name}";
5353
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
5466 export const ROUTES: readonly Route[] = [
5567 { method: "get", path: "/api/v1/apps", summary: "List the apps whose repositories you can read; answers { items, next_cursor }", auth: false },
5668 {
@@ -1362,6 +1374,40 @@ export const ROUTES: readonly Route[] = [
13621374 failed: "502: the link could not be sent",
13631375 },
13641376 },
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 },

Summary is long. The 'untrusted' note repeats on all four rows; a shared constant would keep the wording from drifting.

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 },
13651411 {
13661412 method: "post",
13671413 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", () => {
3232 .join("\n")
3333 // A route registered in a loop over issues and pulls is two routes.
3434 .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]!;
3536 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 ),
3740 );
3841 const shape = (p: string) => p.replace(/\{\w+\}|[:*]\w+/g, ":");
3942 const shapes = new Set([...registered].map((r) => shape(r)));
test/shards.test.ts +1
@@ -515,6 +515,7 @@ describe("parseShardSpec", () => {
515515 expect(parseShardSpec(" 2/4 ")).toEqual({ ok: true, index: 2, total: 4 });
516516 for (const bad of ["0/3", "1/0", "4/3", "1", "1-3", "a/b", "1/3/5"])
517517 expect(parseShardSpec(bad).ok).toBe(false);
518 expect(parseShardSpec("1000/1000")).toEqual({ ok: true, index: 1000, total: 1000 });
518519 });
519520 });
520521

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs