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

Merged opened this

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

Merged

scout/forge:scout/5243-the-openapi-document-describes-browsing → main 7 commits fork 2 threads · 2 unresolved

  • Merged. scout/5243-the-openapi-document-describes-browsing was merged into main as 52ba9e5.

Review

Sign in to review this change.
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
105105 export { route as getApiV1TelemetrySystem } from "./getApiV1TelemetrySystem.ts";
106106 export { route as getApiV1Tokens } from "./getApiV1Tokens.ts";
107107 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";
108112 export { route as getApiV1UserOriginsIdBrowseOwnerNameIssues } from "./getApiV1UserOriginsIdBrowseOwnerNameIssues.ts";
109113 export { route as getApiV1UserOriginsIdBrowseOwnerNameIssuesNumber } from "./getApiV1UserOriginsIdBrowseOwnerNameIssuesNumber.ts";
114 export { route as getApiV1UserOriginsIdBrowseOwnerNameLabels } from "./getApiV1UserOriginsIdBrowseOwnerNameLabels.ts";
115 export { route as getApiV1UserOriginsIdBrowseOwnerNamePathsRef } from "./getApiV1UserOriginsIdBrowseOwnerNamePathsRef.ts";
110116 export { route as getApiV1UserOriginsIdBrowseOwnerNamePulls } from "./getApiV1UserOriginsIdBrowseOwnerNamePulls.ts";
111117 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";
112121 export { route as getApiV1UserOriginsIdMirrorJob } from "./getApiV1UserOriginsIdMirrorJob.ts";
113122 export { route as getApiV1UserOriginsIdMirrorJobStream } from "./getApiV1UserOriginsIdMirrorJobStream.ts";
114123 export { route as getApiV1UsersUsernameSignins } from "./getApiV1UsersUsernameSignins.ts";
src/contract/openapi/shared.ts +6
@@ -48,3 +48,9 @@ export const BROWSE_REFUSALS = {
4848 origin_unauthorized: "502: the origin refused this forge's credential; reconnect it",
4949 origin_unreachable: "502: the origin could not be reached",
5050 };
51
52 /** The refusals every live browse of an origin's code can answer (`sourceFailure` in `http/api.ts`); the connecting user only. */
53 export const BROWSE_CODE_REFUSALS = {
54 ...BROWSE_REFUSALS,
55 not_found: "404: no such origin of yours, or the origin has no such repository, ref or path",
56 };
test/openapi-coverage.test.ts −9
@@ -43,19 +43,10 @@ const NOT_YET_DESCRIBED: readonly string[] = [
4343 "GET /api/v1/user/origins",
4444 "GET /api/v1/user/origins/oauth/{}",
4545 "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/{}",
5046 "GET /api/v1/user/origins/{}/browse/{}/{}/issues",
5147 "GET /api/v1/user/origins/{}/browse/{}/{}/issues/{}",
52 "GET /api/v1/user/origins/{}/browse/{}/{}/labels",
53 "GET /api/v1/user/origins/{}/browse/{}/{}/paths/{}",
5448 "GET /api/v1/user/origins/{}/browse/{}/{}/pulls",
5549 "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/{}/{}",
5950 "GET /api/v1/user/origins/{}/mirror/{}/stream",
6051 "GET /api/v1/user/origins/{}/repos",
6152 "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", () =>
236236 expect(doc.paths[`${A}/{env}/redeploy`].post.responses["202"]).toBeDefined();
237237 });
238238
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
239272 test("mirroring and forking from an origin, its job and stream, and origin apps are described", () => {
240273 const doc = openapi("https://f.test", "1") as any;
241274 const want: [string, string][] = [

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs