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

Merged opened this

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

Merged

scout/forge:scout/5240-the-openapi-document-describes-a-reposit → main 2 commits fork

  • Merged. scout/5240-the-openapi-document-describes-a-reposit was merged into main as 123fb45.

Review

Sign in to review this change.
2 files changed +148 −0
src/contract/openapi.ts +54
@@ -150,12 +150,30 @@ export const ROUTES: readonly Route[] = [
150150 { 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" } },
151151 { method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string" } },
152152 { 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 },
153170 {
154171 method: "get",
155172 path: REPO,
156173 summary:
157174 "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",
158175 auth: false,
176 refusals: { not_found: "404: no such repository, or it is not readable by the caller" },
159177 },
160178 {
161179 method: "post",
@@ -402,7 +420,30 @@ export const ROUTES: readonly Route[] = [
402420 visibility: "string",
403421 archived: "boolean",
404422 showProvenance: "boolean",
423 defaultBranch: "string",
424 protectDefault: "boolean",
425 requireReview: "boolean",
426 requireChecks: "boolean",
427 crewOutsiders: "boolean",
428 decisionsBy: "string",
429 plansNeedNod: "boolean",
405430 },
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" },
406447 },
407448 {
408449 method: "get",
@@ -486,6 +527,19 @@ export const ROUTES: readonly Route[] = [
486527 severed: "409: this forge is severed and follows nothing",
487528 },
488529 },
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 },
489543 {
490544 method: "get",
491545 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs