Public: anyone can read this; the crew can change it.
The OpenAPI document describes a repository's code writes: create and delete a branch, write a file #5125
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5112-the-openapi-document-describes-a-reposit → main 2 commits fork 3 threads · 3 unresolved
src/contract/openapi.ts +47
| @@ -698,6 +698,53 @@ export const ROUTES: readonly Route[] = [ | ||
| 698 | 698 | body: { branch: "string", base: "string", message: "string", newBranch: "string", files: "array", openPull: "object" }, |
| 699 | 699 | refusals: { no_files: "files holds nothing to write" }, |
| 700 | 700 | }, |
| 701 | { | |
| 702 | method: "post", | |
| 703 | path: `${REPO}/branches`, | |
| 704 | summary: | |
| 705 | "Create a branch (rw scope), from `from` or the default branch; answers 201 with the branch", | |
| 706 | auth: true, | |
| 707 | status: 201, | |
| 708 | body: { name: "string", from: "string" }, | |
| 709 | refusals: { | |
| 710 | invalid_name: "name is not a branch name", | |
| 711 | unknown_start: "from names nothing in the repository", | |
| 712 | exists: "the branch is already there (409)", | |
| 713 | origin_refused: "a mirror's origin refused the branch", | |
| 714 | }, | |
| 715 | }, | |
| 716 | { | |
| 717 | method: "delete", | |
| 718 | path: `${REPO}/branches/{branch}`, | |
| 719 | summary: | |
| 720 | "Delete a branch (rw scope); the name may hold slashes; answers 204 with no body", | |
| 721 | auth: true, | |
| 722 | status: 204, | |
| 723 | refusals: { | |
| 724 | invalid_name: "not a branch name", | |
| 725 | default_branch: "the default branch cannot be deleted (409)", | |
| 726 | unknown_branch: "no such branch (404)", | |
| 727 | origin_refused: "a mirror's origin refused the deletion", | |
| 728 | }, | |
| 729 | }, | |
| 730 | { | |
| 731 | method: "post", | |
| 732 | path: `${REPO}/contents`, | |
| 733 | summary: | |
| 734 | "Commit files to a branch without git (rw scope): files is [{ path, content }]; answers 201 with the commit", | |
| 735 | auth: true, | |
| 736 | status: 201, | |
| 737 | body: { branch: "string", base: "string", message: "string", files: "array" }, | |
| 738 | refusals: { | |
| 739 | invalid_branch: "branch or base is not a safe ref name", | |
| 740 | invalid_path: "a file path is not a safe relative path", | |
| 741 | path_conflict: "a path is a directory in the tree, or an ancestor of it is a file", | |
| 742 | no_files: "files holds nothing to write", | |
| 743 | branch_moved: "the branch moved while committing (409)", | |
| 744 | land_refused: "the commit could not be landed on the branch", | |
| 745 | git: "git failed while committing", | |
outdated
This line promises protection that |
||
| 746 | }, | |
| 747 | }, | |
| 701 | 748 | { |
| 702 | 749 | method: "post", |
| 703 | 750 | path: "/api/v1/thoughts", |
test/openapi.test.ts +20 −1
| @@ -33,7 +33,7 @@ test("every described route is registered in the router", () => { | ||
| 33 | 33 | const registered = new Set( |
| 34 | 34 | [...src.matchAll(/"(GET|POST|PATCH|DELETE|PUT)",\s*"([^"]+)"/g)].map((m) => `${m[1]} ${m[2]}`), |
| 35 | 35 | ); |
| 36 | const shape = (p: string) => p.replace(/\{\w+\}|:\w+/g, ":"); | |
| 36 | const shape = (p: string) => p.replace(/\{\w+\}|[:*]\w+/g, ":"); | |
| 37 | 37 | const shapes = new Set([...registered].map((r) => shape(r))); |
| 38 | 38 | for (const r of ROUTES) { |
| 39 | 39 | const want = shape(`${r.method.toUpperCase()} ${r.path}`); |
| @@ -183,3 +183,22 @@ test("an app's routes are described, and env values are never read back", () => | ||
| 183 | 183 | expect(doc.paths[`${A}/env`].get.summary).toContain("never read back"); |
| 184 | 184 | expect(doc.paths[`${A}/{env}/redeploy`].post.responses["202"]).toBeDefined(); |
| 185 | 185 | }); |
| 186 | ||
| 187 | test("a repository's code writes are described: branches and contents", () => { | |
| 188 | const doc = openapi("https://f.test", "1") as any; | |
| 189 | const R = "/api/v1/repos/{owner}/{name}"; | |
| 190 | expect(doc.paths[`${R}/branches`]?.post).toBeDefined(); | |
This test passes whatever |
||
| 191 | expect(doc.paths[`${R}/branches/{branch}`]?.delete).toBeDefined(); | |
| 192 | expect(doc.paths[`${R}/contents`]?.post).toBeDefined(); | |
| 193 | expect(doc.paths[`${R}/branches`].post.responses["201"]).toBeDefined(); | |
| 194 | expect(doc.paths[`${R}/branches/{branch}`].delete.responses["204"]).toBeDefined(); | |
| 195 | }); | |
| 196 | ||
| 197 | test("the contents row names the refusals of its own handler, not /edits'", () => { | |
| 198 | const doc = openapi("https://f.test", "1") as any; | |
| 199 | const R = "/api/v1/repos/{owner}/{name}"; | |
| 200 | const text = JSON.stringify(doc.paths[`${R}/contents`].post.responses); | |
| 201 | expect(text).toContain("path_conflict"); | |
| 202 | expect(text).not.toContain("protected_branch"); | |
| 203 | expect(text).not.toContain("file_too_large"); | |
| 204 | }); |
High (correctness of the contract):
duplicate_path,too_many_files,file_too_large,commit_too_largeandprotected_branch(lines 741-745) areeditFilescodes (src/actions/contents.ts:122-163).POST /contentsgoes throughcommitToBranch→commitFiles, which never produces them. List what it does produce:invalid_branch,invalid_path,path_conflict,no_files,branch_moved(409),land_refused,git.