Public: anyone can read this; the crew can change it.
The OpenAPI document describes browsing an origin's code: branches, tags, labels, paths, tree, blob and commits #5275
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5243-the-openapi-document-describes-browsing → main 7 commits fork 2 threads · 2 unresolved
13 files
changed +130
−9
src/contract/openapi/routes/getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { BROWSE, BROWSE_CODE_REFUSALS, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${BROWSE}/blob/{ref}/{path}`, | |
| 6 | summary: "Read one file of a repository the origin holds, live (`path` may span several segments, e.g. src/a/b.ts: send the slashes as they are) (the connecting user only); answers { type, ref, path, size, encoding (utf-8 or base64), content }; a file over the limit answers 413 { type: \"file\", tooLarge: true, size }", | |
| 7 | auth: true, | |
| 8 | refusals: { ...BROWSE_CODE_REFUSALS, too_large: "413: that is too large to read here" }, | |
| 9 | }; |
src/contract/openapi/routes/getApiV1UserOriginsIdBrowseOwnerNameBranches.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { BROWSE, BROWSE_CODE_REFUSALS, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${BROWSE}/branches`, | |
| 6 | summary: "List the branches of a repository the origin holds, live (the connecting user only); answers an array of { name, oid, isDefault, protected }", | |
| 7 | auth: true, | |
| 8 | refusals: BROWSE_CODE_REFUSALS, | |
| 9 | }; |
src/contract/openapi/routes/getApiV1UserOriginsIdBrowseOwnerNameCommits.ts added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | import { BROWSE, BROWSE_CODE_REFUSALS, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${BROWSE}/commits`, | |
| 6 | summary: "List commits of a repository the origin holds, live, newest first (the connecting user only); answers { items: [{ oid, parents, author, email, date, subject, body, login? }], next_cursor }; ref defaults to the default branch", | |
| 7 | auth: true, | |
| 8 | query: { ref: "string", cursor: "string" }, | |
| 9 | refusals: BROWSE_CODE_REFUSALS, | |
| 10 | }; |
src/contract/openapi/routes/getApiV1UserOriginsIdBrowseOwnerNameCommitsSha.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { BROWSE, BROWSE_CODE_REFUSALS, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${BROWSE}/commits/{sha}`, | |
| 6 | summary: "Read one commit of a repository the origin holds, live (the connecting user only): the commit's fields (oid, parents, author, email, date, subject, body, login?) with its diff", | |
| 7 | auth: true, | |
| 8 | refusals: BROWSE_CODE_REFUSALS, | |
| 9 | }; |
src/contract/openapi/routes/getApiV1UserOriginsIdBrowseOwnerNameLabels.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { BROWSE, BROWSE_CODE_REFUSALS, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${BROWSE}/labels`, | |
| 6 | summary: "List the labels of a repository the origin holds, live (the connecting user only); answers an array of { id, name, color }", | |
| 7 | auth: true, | |
| 8 | refusals: BROWSE_CODE_REFUSALS, | |
| 9 | }; |
src/contract/openapi/routes/getApiV1UserOriginsIdBrowseOwnerNamePathsRef.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { BROWSE, BROWSE_CODE_REFUSALS, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${BROWSE}/paths/{ref}`, | |
| 6 | summary: "List every file path at a ref of a repository the origin holds, live (the connecting user only); answers { paths, truncated }", | |
| 7 | auth: true, | |
| 8 | refusals: BROWSE_CODE_REFUSALS, | |
| 9 | }; |
src/contract/openapi/routes/getApiV1UserOriginsIdBrowseOwnerNameTags.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { BROWSE, BROWSE_CODE_REFUSALS, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${BROWSE}/tags`, | |
| 6 | summary: "List the tags of a repository the origin holds, live (the connecting user only); answers an array of { name, oid, commit }", | |
| 7 | auth: true, | |
| 8 | refusals: BROWSE_CODE_REFUSALS, | |
| 9 | }; |
src/contract/openapi/routes/getApiV1UserOriginsIdBrowseOwnerNameTreeRef.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { BROWSE, BROWSE_CODE_REFUSALS, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${BROWSE}/tree/{ref}`, | |
| 6 | summary: "List the entries at the root of a ref of a repository the origin holds, live (the connecting user only); answers { type: \"dir\", ref, path, entries: [{ mode, type, oid, size, name }] }", | |
| 7 | auth: true, | |
| 8 | refusals: BROWSE_CODE_REFUSALS, | |
| 9 | }; |
src/contract/openapi/routes/getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { BROWSE, BROWSE_CODE_REFUSALS, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${BROWSE}/tree/{ref}/{path}`, | |
| 6 | summary: "List the entries of one directory at a ref of a repository the origin holds, live (`path` may span several segments, e.g. src/a/b: send the slashes as they are) (the connecting user only); answers { type: \"dir\", ref, path, entries: [{ mode, type, oid, size, name }] }", | |
| 7 | auth: true, | |
| 8 | refusals: BROWSE_CODE_REFUSALS, | |
| 9 | }; |
src/contract/openapi/routes/index.ts +9
| @@ -105,10 +105,19 @@ export { route as getApiV1TelemetrySources } from "./getApiV1TelemetrySources.ts | ||
| 105 | 105 | export { route as getApiV1TelemetrySystem } from "./getApiV1TelemetrySystem.ts"; |
| 106 | 106 | export { route as getApiV1Tokens } from "./getApiV1Tokens.ts"; |
| 107 | 107 | export { route as getApiV1UserOriginApps } from "./getApiV1UserOriginApps.ts"; |
| 108 | export { route as getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath } from "./getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath.ts"; | |
| 109 | export { route as getApiV1UserOriginsIdBrowseOwnerNameBranches } from "./getApiV1UserOriginsIdBrowseOwnerNameBranches.ts"; | |
| 110 | export { route as getApiV1UserOriginsIdBrowseOwnerNameCommits } from "./getApiV1UserOriginsIdBrowseOwnerNameCommits.ts"; | |
| 111 | export { route as getApiV1UserOriginsIdBrowseOwnerNameCommitsSha } from "./getApiV1UserOriginsIdBrowseOwnerNameCommitsSha.ts"; | |
| 108 | 112 | export { route as getApiV1UserOriginsIdBrowseOwnerNameIssues } from "./getApiV1UserOriginsIdBrowseOwnerNameIssues.ts"; |
| 109 | 113 | export { route as getApiV1UserOriginsIdBrowseOwnerNameIssuesNumber } from "./getApiV1UserOriginsIdBrowseOwnerNameIssuesNumber.ts"; |
| 114 | export { route as getApiV1UserOriginsIdBrowseOwnerNameLabels } from "./getApiV1UserOriginsIdBrowseOwnerNameLabels.ts"; | |
| 115 | export { route as getApiV1UserOriginsIdBrowseOwnerNamePathsRef } from "./getApiV1UserOriginsIdBrowseOwnerNamePathsRef.ts"; | |
| 110 | 116 | export { route as getApiV1UserOriginsIdBrowseOwnerNamePulls } from "./getApiV1UserOriginsIdBrowseOwnerNamePulls.ts"; |
| 111 | 117 | export { route as getApiV1UserOriginsIdBrowseOwnerNamePullsNumber } from "./getApiV1UserOriginsIdBrowseOwnerNamePullsNumber.ts"; |
| 118 | export { route as getApiV1UserOriginsIdBrowseOwnerNameTags } from "./getApiV1UserOriginsIdBrowseOwnerNameTags.ts"; | |
| 119 | export { route as getApiV1UserOriginsIdBrowseOwnerNameTreeRef } from "./getApiV1UserOriginsIdBrowseOwnerNameTreeRef.ts"; | |
| 120 | export { route as getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath } from "./getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath.ts"; | |
| 112 | 121 | export { route as getApiV1UserOriginsIdMirrorJob } from "./getApiV1UserOriginsIdMirrorJob.ts"; |
| 113 | 122 | export { route as getApiV1UserOriginsIdMirrorJobStream } from "./getApiV1UserOriginsIdMirrorJobStream.ts"; |
| 114 | 123 | export { route as getApiV1UsersUsernameSignins } from "./getApiV1UsersUsernameSignins.ts"; |
test/openapi-coverage.test.ts −9
| @@ -43,19 +43,10 @@ const NOT_YET_DESCRIBED: readonly string[] = [ | ||
| 43 | 43 | "GET /api/v1/user/origins", |
| 44 | 44 | "GET /api/v1/user/origins/oauth/{}", |
| 45 | 45 | "GET /api/v1/user/origins/{}/browse/{}/{}", |
| 46 | "GET /api/v1/user/origins/{}/browse/{}/{}/blob/{}/{}", | |
| 47 | "GET /api/v1/user/origins/{}/browse/{}/{}/branches", | |
| 48 | "GET /api/v1/user/origins/{}/browse/{}/{}/commits", | |
| 49 | "GET /api/v1/user/origins/{}/browse/{}/{}/commits/{}", | |
| 50 | 46 | "GET /api/v1/user/origins/{}/browse/{}/{}/issues", |
| 51 | 47 | "GET /api/v1/user/origins/{}/browse/{}/{}/issues/{}", |
| 52 | "GET /api/v1/user/origins/{}/browse/{}/{}/labels", | |
| 53 | "GET /api/v1/user/origins/{}/browse/{}/{}/paths/{}", | |
| 54 | 48 | "GET /api/v1/user/origins/{}/browse/{}/{}/pulls", |
| 55 | 49 | "GET /api/v1/user/origins/{}/browse/{}/{}/pulls/{}", |
| 56 | "GET /api/v1/user/origins/{}/browse/{}/{}/tags", | |
| 57 | "GET /api/v1/user/origins/{}/browse/{}/{}/tree/{}", | |
| 58 | "GET /api/v1/user/origins/{}/browse/{}/{}/tree/{}/{}", | |
| 59 | 50 | "GET /api/v1/user/origins/{}/mirror/{}/stream", |
| 60 | 51 | "GET /api/v1/user/origins/{}/repos", |
| 61 | 52 | "GET /api/v1/user/pins", |
test/openapi.test.ts +33
| @@ -236,6 +236,39 @@ test("an app's routes are described, and env values are never read back", () => | ||
| 236 | 236 | expect(doc.paths[`${A}/{env}/redeploy`].post.responses["202"]).toBeDefined(); |
| 237 | 237 | }); |
| 238 | 238 | |
| 239 | test("browsing an origin's code is described, and a refusal matches what the route answers", async () => { | |
| 240 | const doc = openapi("https://f.test", "1") as any; | |
| 241 | const B = "/api/v1/user/origins/{id}/browse/{owner}/{name}"; | |
| 242 | for (const tail of ["branches", "tags", "labels", "paths/{ref}", "tree/{ref}", "tree/{ref}/{path}", "blob/{ref}/{path}", "commits", "commits/{sha}"]) { | |
| 243 | const op = doc.paths[`${B}/${tail}`]?.get; | |
| 244 | expect(op, tail).toBeDefined(); | |
| 245 | expect(op.security, tail).toBeDefined(); | |
| 246 | expect(op.responses.default.description, tail).toContain("`origin_unreachable`"); | |
| 247 | expect(op.summary, tail).toContain("(the connecting user only)"); | |
| 248 | } | |
| 249 | expect(doc.paths[`${B}/tree/{ref}/{path}`].get.summary).toContain("src/a/b:"); | |
| 250 | expect(doc.paths[`${B}/commits`].get.parameters.map((p: any) => p.name)).toContain("cursor"); | |
| 251 | expect(doc.paths[`${B}/blob/{ref}/{path}`].get.responses.default.description).toContain("`too_large`"); | |
| 252 | ||
| 253 | const dir = await mkdtemp(join(tmpdir(), "forge-openapi-")); | |
| 254 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 255 | const servers = await serve(app); | |
| 256 | try { | |
| 257 | const live = ["branches", "tags", "labels", "paths/main", "tree/main", "tree/main/a", "blob/main/a/b", "commits", "commits/abc"]; | |
| 258 | const docTails = ["branches", "tags", "labels", "paths/{ref}", "tree/{ref}", "tree/{ref}/{path}", "blob/{ref}/{path}", "commits", "commits/{sha}"]; | |
| 259 | for (const [i, tail] of live.entries()) { | |
| 260 | const res = await fetch(`${servers.url}/api/v1/user/origins/x/browse/o/n/${tail}`); | |
| 261 | expect(res.status, tail).toBe(401); | |
| 262 | expect(((await res.json()) as any).error, tail).toBe("unauthorized"); | |
| 263 | expect(doc.paths[`${B}/${docTails[i]}`].get.responses.default.description, tail).toContain("`unauthorized`"); | |
| 264 | } | |
| 265 | } finally { | |
| 266 | servers.stop(); | |
| 267 | app.close(); | |
| 268 | await rm(dir, { recursive: true, force: true }); | |
| 269 | } | |
| 270 | }); | |
| 271 | ||
| 239 | 272 | test("mirroring and forking from an origin, its job and stream, and origin apps are described", () => { |
| 240 | 273 | const doc = openapi("https://f.test", "1") as any; |
| 241 | 274 | const want: [string, string][] = [ |
new above
offline