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

Merged opened this

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

Merged

scout/forge:scout/5040-the-openapi-document-describes-an-issue → main 4 commits fork

  • Merged. scout/5040-the-openapi-document-describes-an-issue was merged into main as 2d4c142.

Review

Sign in to review this change.
3 files changed +85 −0
src/contract/openapi.ts +60
@@ -36,6 +36,16 @@ const APP_REFUSALS = {
3636 name_taken: "the owner already has a repository of this name",
3737 };
3838
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
3949 const REPO = "/api/v1/repos/{owner}/{name}";
4050
4151 export const ROUTES: readonly Route[] = [
@@ -1057,6 +1067,56 @@ export const ROUTES: readonly Route[] = [
10571067 body: { source: "object", owner: "string", name: "string", visibility: "string", branch: "string", after: "string", crew: "object" },
10581068 },
10591069 { 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 },
10601120 {
10611121 method: "get",
10621122 path: `${REPO}/flow`,
test/mcp.test.ts +9
@@ -1220,6 +1220,15 @@ describe("the forge over MCP", () => {
12201220 expect(doc.openapi).toBe("3.1.0");
12211221 expect(doc.paths["/mcp"].post).toBeDefined();
12221222 });
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 });
12231232 test("GET /api/v1/openapi.json redirects to the document", async () => {
12241233 const res = await fetch(`${base}/api/v1/openapi.json`, { redirect: "manual" });
12251234 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", ()
123123 expect(post.responses.default.description).toContain(`\`${code}\``);
124124 });
125125
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
126142 test("no method and path is described twice, so no row is dead", () => {
127143 const seen = new Set<string>();
128144 for (const r of ROUTES) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs