Public: anyone can read this; the crew can change it.
The OpenAPI document describes a repository itself: create, read, edit, delete and its upstream #5260
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5240-the-openapi-document-describes-a-reposit → main 2 commits fork
2 files
changed +148
−0
src/contract/openapi.ts +54
| @@ -150,12 +150,30 @@ export const ROUTES: readonly Route[] = [ | ||
| 150 | 150 | { method: "get", path: "/api/v1/explore", summary: "Explore apps and repositories; `q` takes words and `kind:`/`owner:` filters; answers { query, items }", auth: false, query: { q: "string" } }, |
| 151 | 151 | { method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string" } }, |
| 152 | 152 | { method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false }, |
| 153 | { | |
| 154 | method: "post", | |
| 155 | path: "/api/v1/repos", | |
| 156 | summary: | |
| 157 | "Make a repository under you or an organisation you can write to (rw scope; an agent owns none); `visibility` is public, anything else private; answers 201 with the repository", | |
| 158 | auth: true, | |
| 159 | status: 201, | |
| 160 | body: { name: "string", owner: "string", visibility: "string", description: "string" }, | |
| 161 | refusals: { | |
| 162 | invalid_name: "422: not a name a repository may have", | |
| 163 | name_taken: "422: the owner already has a repository of this name", | |
| 164 | forbidden_owner: "403: `owner` is not you or an organisation you can write to, or the caller is an agent", | |
| 165 | repository_limit: "403: at the repository limit", | |
| 166 | storage_limit: "403: at the storage limit", | |
| 167 | unverified: "403: the account has not verified its email", | |
| 168 | }, | |
| 169 | }, | |
| 153 | 170 | { |
| 154 | 171 | method: "get", |
| 155 | 172 | path: REPO, |
| 156 | 173 | summary: |
| 157 | 174 | "Read one repository; `provenance` is where a migrated one came from ({ source, sourceUrl, parents: [{ fullName, url }], migratedAt }), null when it was not migrated or its owner hid it", |
| 158 | 175 | auth: false, |
| 176 | refusals: { not_found: "404: no such repository, or it is not readable by the caller" }, | |
| 159 | 177 | }, |
| 160 | 178 | { |
| 161 | 179 | method: "post", |
| @@ -402,7 +420,30 @@ export const ROUTES: readonly Route[] = [ | ||
| 402 | 420 | visibility: "string", |
| 403 | 421 | archived: "boolean", |
| 404 | 422 | showProvenance: "boolean", |
| 423 | defaultBranch: "string", | |
| 424 | protectDefault: "boolean", | |
| 425 | requireReview: "boolean", | |
| 426 | requireChecks: "boolean", | |
| 427 | crewOutsiders: "boolean", | |
| 428 | decisionsBy: "string", | |
| 429 | plansNeedNod: "boolean", | |
| 405 | 430 | }, |
| 431 | refusals: { | |
| 432 | bad_archived: "422: archived is true or false", | |
| 433 | bad_decisions_by: "422: decisionsBy is me, decider or decider_tell", | |
| 434 | bad_plans_need_nod: "422: plansNeedNod is true or false", | |
| 435 | bad_show_provenance: "422: showProvenance is true or false", | |
| 436 | no_provenance: "422: this repository was not migrated here", | |
| 437 | forbidden: "403: only an admin archives or wakes", | |
| 438 | not_found: "404: no such repository, or it is not readable by the caller", | |
| 439 | }, | |
| 440 | }, | |
| 441 | { | |
| 442 | method: "delete", | |
| 443 | path: REPO, | |
| 444 | summary: "Delete a repository for good (admin scope, an admin's); answers {}", | |
| 445 | auth: true, | |
| 446 | refusals: { not_found: "404: no such repository, or the caller is not its admin", delete_failed: "500: the repository could not be removed" }, | |
| 406 | 447 | }, |
| 407 | 448 | { |
| 408 | 449 | method: "get", |
| @@ -486,6 +527,19 @@ export const ROUTES: readonly Route[] = [ | ||
| 486 | 527 | severed: "409: this forge is severed and follows nothing", |
| 487 | 528 | }, |
| 488 | 529 | }, |
| 530 | { | |
| 531 | method: "put", | |
| 532 | path: `${REPO}/upstream`, | |
| 533 | summary: "Follow an outside upstream, or stop (admin scope, repository admin); answers { following }", | |
| 534 | auth: true, | |
| 535 | body: { url: "string", branch: "string", enabled: "boolean" }, | |
| 536 | refusals: { | |
| 537 | bad_enabled: "422: enabled is true or false", | |
| 538 | invalid_url: "422: not an https or http git address", | |
| 539 | invalid_branch: "422: not a branch name git accepts", | |
| 540 | not_found: "404: no such repository, or it is not readable by the caller", | |
| 541 | }, | |
| 542 | }, | |
| 489 | 543 | { |
| 490 | 544 | method: "get", |
| 491 | 545 | path: `${REPO}/upstream`, |
test/openapi-repo.test.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** The OpenAPI document describes a repository itself, and its refusals are the ones the routes answer. */ | |
| 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 { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { openapi } from "../src/contract/openapi.ts"; | |
| 9 | import { createSession } from "../src/core/sessions.ts"; | |
| 10 | import { createUser } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | const R = "/api/v1/repos/{owner}/{name}"; | |
| 17 | const WANT: readonly (readonly [string, string])[] = [ | |
| 18 | ["post", "/api/v1/repos"], | |
| 19 | ["get", R], | |
| 20 | ["patch", R], | |
| 21 | ["delete", R], | |
| 22 | ["get", `${R}/upstream`], | |
| 23 | ["put", `${R}/upstream`], | |
| 24 | ["post", `${R}/upstream/check`], | |
| 25 | ]; | |
| 26 | ||
| 27 | test("each repository path and method is in the document", () => { | |
| 28 | const doc = openapi("https://f.test", "1") as any; | |
| 29 | for (const [method, path] of WANT) expect(doc.paths[path]?.[method], `${method} ${path}`).toBeDefined(); | |
| 30 | expect(doc.paths["/api/v1/repos"].post.responses["201"]).toBeDefined(); | |
| 31 | }); | |
| 32 | ||
| 33 | let dataDir: string; | |
| 34 | let app: App; | |
| 35 | let servers: Servers; | |
| 36 | let cookie = ""; | |
| 37 | const cells = process.env["FORGE_CELLS"]; | |
| 38 | ||
| 39 | beforeAll(async () => { | |
| 40 | process.env["FORGE_CELLS"] = "off"; | |
| 41 | dataDir = await mkdtemp(join(tmpdir(), "forge-openapi-repo-")); | |
| 42 | app = createApp({ dataDir, port: 0 }); | |
| 43 | servers = await serve(app); | |
| 44 | const made = await createUser(app.db, { | |
| 45 | origin: "seed", | |
| 46 | trusted: true, | |
| 47 | username: "alice", | |
| 48 | email: "[email protected]", | |
| 49 | password: "a-good-passphrase", | |
| 50 | adminIfFirst: true, | |
| 51 | }); | |
| 52 | if (!made.ok) throw new Error(String(made.error)); | |
| 53 | cookie = `forge_session=${createSession(app.db, made.value.id).token}`; | |
| 54 | const repo = await createRepo(app, made.value, { name: "r", visibility: "public" }); | |
| 55 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 56 | }); | |
| 57 | afterAll(async () => { | |
| 58 | await servers.stop(); | |
| 59 | app.close(); | |
| 60 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 61 | else process.env["FORGE_CELLS"] = cells; | |
| 62 | await rm(dataDir, { recursive: true, force: true }); | |
| 63 | }); | |
| 64 | ||
| 65 | async function call(method: string, path: string, body: unknown): Promise<{ status: number; error: unknown }> { | |
| 66 | const res = await fetch(`${servers.url}${path}`, { | |
| 67 | method, | |
| 68 | headers: { cookie, origin: servers.url, "content-type": "application/json" }, | |
| 69 | body: JSON.stringify(body), | |
| 70 | }); | |
| 71 | const json = (await res.json().catch(() => ({}))) as { error?: unknown }; | |
| 72 | return { status: res.status, error: json.error }; | |
| 73 | } | |
| 74 | ||
| 75 | function documented(method: string, path: string, code: string): void { | |
| 76 | const doc = openapi("https://f.test", "1") as any; | |
| 77 | expect(doc.paths[path][method].responses.default.description).toContain(`\`${code}\``); | |
| 78 | } | |
| 79 | ||
| 80 | test("one refusal per row is the code the route answers", async () => { | |
| 81 | const cases: readonly [string, string, string, unknown, string, number][] = [ | |
| 82 | ["post", "/api/v1/repos", "/api/v1/repos", { name: "bad name!" }, "invalid_name", 422], | |
| 83 | ["patch", R, "/api/v1/repos/alice/r", { archived: "yes" }, "bad_archived", 422], | |
| 84 | ["put", `${R}/upstream`, "/api/v1/repos/alice/r/upstream", { url: "https://x.test/r.git" }, "bad_enabled", 422], | |
| 85 | ["delete", R, "/api/v1/repos/alice/none", {}, "not_found", 404], | |
| 86 | ["get", R, "/api/v1/repos/alice/none", undefined, "not_found", 404], | |
| 87 | ]; | |
| 88 | for (const [method, doc, path, body, code, status] of cases) { | |
| 89 | const got = await call(method.toUpperCase(), path, body); | |
| 90 | expect(got.status).toBe(status); | |
| 91 | expect(got.error).toBe(code); | |
| 92 | documented(method, doc, code); | |
| 93 | } | |
| 94 | }); |
new above
offline