Public: anyone can read this; the crew can change it.
The OpenAPI document describes an issue's teams, labels and private repository, and a pull's labels #5057
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5040-the-openapi-document-describes-an-issue → main 4 commits fork
3 files
changed +85
−0
src/contract/openapi.ts +60
| @@ -36,6 +36,16 @@ const APP_REFUSALS = { | ||
| 36 | 36 | name_taken: "the owner already has a repository of this name", |
| 37 | 37 | }; |
| 38 | 38 | |
| 39 | /** What the label routes for issues and pulls refuse by stable `error` code. */ | |
| 40 | const LABEL_REFUSALS = { | |
| 41 | unknown_label: "a name or id in the set names no label of the repository", | |
| 42 | forbidden: "the caller cannot write to the repository", | |
| 43 | mirrored: "the repository mirrors an origin; do it there", | |
| 44 | origin_owner_only: "only the person who connected the origin can write through it", | |
| 45 | origin_unreachable: "the origin could not be reached (502)", | |
| 46 | origin_refused: "the origin refused the change (409)", | |
| 47 | }; | |
| 48 | ||
| 39 | 49 | const REPO = "/api/v1/repos/{owner}/{name}"; |
| 40 | 50 | |
| 41 | 51 | export const ROUTES: readonly Route[] = [ |
| @@ -1057,6 +1067,56 @@ export const ROUTES: readonly Route[] = [ | ||
| 1057 | 1067 | body: { source: "object", owner: "string", name: "string", visibility: "string", branch: "string", after: "string", crew: "object" }, |
| 1058 | 1068 | }, |
| 1059 | 1069 | { method: "get", path: "/api/v1/migrate/{job}", summary: "Read a migration job's stage", auth: true }, |
| 1070 | { | |
| 1071 | method: "get", | |
| 1072 | path: `${REPO}/issues/{number}/teams`, | |
| 1073 | summary: "Read the crew teams named on an issue; answers { teams }, a list of team slugs", | |
| 1074 | auth: false, | |
| 1075 | }, | |
| 1076 | { | |
| 1077 | method: "put", | |
| 1078 | path: `${REPO}/issues/{number}/teams`, | |
| 1079 | summary: "Replace the crew teams named on an issue (at most two); the pull that closes it is reviewed by each; answers { teams }, the slugs now named. A body without a list of slug strings is a 400", | |
| 1080 | auth: true, | |
| 1081 | body: { teams: "array" }, | |
| 1082 | refusals: { | |
| 1083 | too_many: "more teams than an issue may name", | |
| 1084 | no_such_team: "a slug names no team", | |
| 1085 | mirrored: "the repository mirrors an origin; do it there", | |
| 1086 | forbidden: "the caller cannot write to the repository", | |
| 1087 | }, | |
| 1088 | }, | |
| 1089 | { | |
| 1090 | method: "put", | |
| 1091 | path: `${REPO}/issues/{number}/labels`, | |
| 1092 | summary: "Replace the label set of an issue (names or ids; the set is all or nothing); answers the labels it now carries as a JSON array", | |
| 1093 | auth: true, | |
| 1094 | body: { labels: "array" }, | |
| 1095 | refusals: LABEL_REFUSALS, | |
| 1096 | }, | |
| 1097 | { | |
| 1098 | method: "put", | |
| 1099 | path: `${REPO}/pulls/{number}/labels`, | |
| 1100 | summary: "Replace the label set of a pull (names or ids; the set is all or nothing); answers the labels it now carries as a JSON array", | |
| 1101 | auth: true, | |
| 1102 | body: { labels: "array" }, | |
| 1103 | refusals: LABEL_REFUSALS, | |
| 1104 | }, | |
| 1105 | { | |
| 1106 | method: "post", | |
| 1107 | path: `${REPO}/issues/{number}/private`, | |
| 1108 | summary: "Make, or find, the private repository where a sealed report is fixed by hand; answers 201 { repo, clone }: `owner/name` and its clone URL", | |
| 1109 | auth: true, | |
| 1110 | status: 201, | |
| 1111 | refusals: { | |
| 1112 | not_found: "no such issue, or sealed reports are not the caller's to see", | |
| 1113 | not_sealed: "the issue is not sealed, so it has nothing to hide", | |
| 1114 | unverified: "the account has not verified its email", | |
| 1115 | name_taken: "the owner already has a repository of the private name", | |
| 1116 | limit: "the owner is at the daily fork limit", | |
| 1117 | fork_failed: "the private repository could not be made", | |
| 1118 | }, | |
| 1119 | }, | |
| 1060 | 1120 | { |
| 1061 | 1121 | method: "get", |
| 1062 | 1122 | path: `${REPO}/flow`, |
test/mcp.test.ts +9
| @@ -1220,6 +1220,15 @@ describe("the forge over MCP", () => { | ||
| 1220 | 1220 | expect(doc.openapi).toBe("3.1.0"); |
| 1221 | 1221 | expect(doc.paths["/mcp"].post).toBeDefined(); |
| 1222 | 1222 | }); |
| 1223 | test("GET /contract/openapi.json serves the triage routes", async () => { | |
| 1224 | const doc = (await (await fetch(`${base}/contract/openapi.json`)).json()) as any; | |
| 1225 | const R = "/api/v1/repos/{owner}/{name}"; | |
| 1226 | expect(doc.paths[`${R}/issues/{number}/teams`].get).toBeDefined(); | |
| 1227 | expect(doc.paths[`${R}/issues/{number}/teams`].put).toBeDefined(); | |
| 1228 | expect(doc.paths[`${R}/issues/{number}/labels`].put).toBeDefined(); | |
| 1229 | expect(doc.paths[`${R}/pulls/{number}/labels`].put).toBeDefined(); | |
| 1230 | expect(doc.paths[`${R}/issues/{number}/private`].post.responses["201"]).toBeDefined(); | |
| 1231 | }); | |
| 1223 | 1232 | test("GET /api/v1/openapi.json redirects to the document", async () => { |
| 1224 | 1233 | const res = await fetch(`${base}/api/v1/openapi.json`, { redirect: "manual" }); |
| 1225 | 1234 | expect(res.status).toBe(308); |
test/openapi.test.ts +16
| @@ -123,6 +123,22 @@ test("making and listing apps is described with every field the route reads", () | ||
| 123 | 123 | expect(post.responses.default.description).toContain(`\`${code}\``); |
| 124 | 124 | }); |
| 125 | 125 | |
| 126 | test("an issue's teams and labels, a pull's labels and an issue's private repository are described", () => { | |
| 127 | const doc = openapi("https://f.test", "1") as any; | |
| 128 | const R = "/api/v1/repos/{owner}/{name}"; | |
| 129 | for (const [method, path] of [ | |
| 130 | ["get", `${R}/issues/{number}/teams`], | |
| 131 | ["put", `${R}/issues/{number}/teams`], | |
| 132 | ["put", `${R}/issues/{number}/labels`], | |
| 133 | ["put", `${R}/pulls/{number}/labels`], | |
| 134 | ["post", `${R}/issues/{number}/private`], | |
| 135 | ] as const) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); | |
| 136 | expect(doc.paths[`${R}/issues/{number}/teams`].put.requestBody.content["application/json"].schema.properties.teams.type).toBe("array"); | |
| 137 | expect(doc.paths[`${R}/issues/{number}/private`].post.responses["201"]).toBeDefined(); | |
| 138 | expect(doc.paths[`${R}/issues/{number}/teams`].get.security).toBeUndefined(); | |
| 139 | expect(doc.paths[`${R}/pulls/{number}/labels`].put.security).toBeDefined(); | |
| 140 | }); | |
| 141 | ||
| 126 | 142 | test("no method and path is described twice, so no row is dead", () => { |
| 127 | 143 | const seen = new Set<string>(); |
| 128 | 144 | for (const r of ROUTES) { |
new above
offline