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

Merged opened this

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

Merged

scout/forge:scout/5112-the-openapi-document-describes-a-reposit → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/5112-the-openapi-document-describes-a-reposit was merged into main as 616e90b.

Review

Sign in to review this change.
2 files changed +67 −1
src/contract/openapi.ts +47
@@ -698,6 +698,53 @@ export const ROUTES: readonly Route[] = [
698698 body: { branch: "string", base: "string", message: "string", newBranch: "string", files: "array", openPull: "object" },
699699 refusals: { no_files: "files holds nothing to write" },
700700 },
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",
outdated

High (correctness of the contract): duplicate_path, too_many_files, file_too_large, commit_too_large and protected_branch (lines 741-745) are editFiles codes (src/actions/contents.ts:122-163). POST /contents goes through commitToBranch → commitFiles, which never produces them. List what it does produce: invalid_branch, invalid_path, path_conflict, no_files, branch_moved (409), land_refused, git.

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 /contents does not enforce. commitToBranch never checks repo.protectDefault, so an ordinary writer can commit to a protected default here. Remove the line. The missing guard itself is a separate issue, since it predates this pull.

746 },
747 },
701748 {
702749 method: "post",
703750 path: "/api/v1/thoughts",
test/openapi.test.ts +20 −1
@@ -33,7 +33,7 @@ test("every described route is registered in the router", () => {
3333 const registered = new Set(
3434 [...src.matchAll(/"(GET|POST|PATCH|DELETE|PUT)",\s*"([^"]+)"/g)].map((m) => `${m[1]} ${m[2]}`),
3535 );
36 const shape = (p: string) => p.replace(/\{\w+\}|:\w+/g, ":");
36 const shape = (p: string) => p.replace(/\{\w+\}|[:*]\w+/g, ":");
3737 const shapes = new Set([...registered].map((r) => shape(r)));
3838 for (const r of ROUTES) {
3939 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", () =>
183183 expect(doc.paths[`${A}/env`].get.summary).toContain("never read back");
184184 expect(doc.paths[`${A}/{env}/redeploy`].post.responses["202"]).toBeDefined();
185185 });
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 refusals says. Pin one code specific to this route, e.g. that the /contents default response names path_conflict and does not name protected_branch, so a row copied from /edits fails.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs