Public: anyone can read this; the crew can change it.

The OpenAPI document describes the caller's origins: list, add, connect by OAuth, sync, read repositories, remove #5167

Merged opened this

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

Merged

scout/forge:scout/5151-the-openapi-document-describes-the-calle → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/5151-the-openapi-document-describes-the-calle was merged into main as 17e591c.

Review

Sign in to review this change.
10 files changed +140 −7
src/contract/openapi/routes/deleteApiV1UserOriginsId.ts added +13
@@ -0,0 +1,13 @@
1 import type { Route } from "../shared.ts";
2
3 const U = "no valid credentials were sent";
4 const F = "the credential is not the account: a session from another origin, or a token without admin scope";
5 const NF = "no such origin among the caller's";
6
7 export const route: Route = {
8 method: "delete",
9 path: "/api/v1/user/origins/{id}",
10 summary: "Disconnect an origin: forget its credential, and revoke an OAuth grant upstream when there is one; answers { revokedUpstream }; repositories already migrated stay; the account only",
11 auth: true,
12 refusals: { unauthorized: U, forbidden: F, not_found: NF },
13 };
src/contract/openapi/routes/getApiV1UserOrigins.ts added +12
@@ -0,0 +1,12 @@
1 import type { Route } from "../shared.ts";
2
3 const U = "no valid credentials were sent";
4 const F = "the credential is not the account: a session from another origin, or a token without admin scope";
5
6 export const route: Route = {
7 method: "get",
8 path: "/api/v1/user/origins",
9 summary: "List the forges (GitHub, Forgejo, Gitea) the caller has connected, never with a token; the caller's account only: a session, the password or an admin-scope token, any other token is a 403",
10 auth: true,
11 refusals: { unauthorized: U, forbidden: F },
12 };
src/contract/openapi/routes/getApiV1UserOriginsIdRepos.ts added +19
@@ -0,0 +1,19 @@
1 import type { Route } from "../shared.ts";
2
3 const U = "no valid credentials were sent";
4 const F = "the credential is not the account: a session from another origin, or a token without admin scope";
5 const NF = "no such origin among the caller's";
6
7 export const route: Route = {
8 method: "get",
9 path: "/api/v1/user/origins/{id}/repos",
10 summary: "List the repositories an origin holds, from the cache unless `refresh=1` asks the origin again (a failed refresh falls back to the cache); pass an item's fullName to POST /api/v1/migrate as { source: { originId, fullName } }; the account only",
11 auth: true,
12 query: { refresh: "string" },
13 refusals: {
14 unauthorized: U,
15 forbidden: F,
16 not_found: NF,
17 origin_unreachable: "502, the origin could not be reached and nothing is cached",
18 },
19 };
src/contract/openapi/routes/getApiV1UserOriginsOauthId.ts added +12
@@ -0,0 +1,12 @@
1 import type { Route } from "../shared.ts";
2
3 const U = "no valid credentials were sent";
4 const F = "the credential is not the account: a session from another origin, or a token without admin scope";
5
6 export const route: Route = {
7 method: "get",
8 path: "/api/v1/user/origins/oauth/{id}",
9 summary: "Poll an OAuth connect by its flowId until it is no longer pending (obey intervalSecs); answers the flow's status, and once connected the origin's id; the account only",
10 auth: true,
11 refusals: { unauthorized: U, forbidden: F, not_found: "no such flow among the caller's" },
12 };
src/contract/openapi/routes/index.ts +7
@@ -24,6 +24,7 @@ export { route as deleteApiV1Sessions } from "./deleteApiV1Sessions.ts";
2424 export { route as deleteApiV1TokensId } from "./deleteApiV1TokensId.ts";
2525 export { route as deleteApiV1UserKeysId } from "./deleteApiV1UserKeysId.ts";
2626 export { route as deleteApiV1UserOriginAppsId } from "./deleteApiV1UserOriginAppsId.ts";
27 export { route as deleteApiV1UserOriginsId } from "./deleteApiV1UserOriginsId.ts";
2728 export { route as deleteApiV1UserPinsOwnerName } from "./deleteApiV1UserPinsOwnerName.ts";
2829 export { route as deleteApiV1UsersUsernameSessionsId } from "./deleteApiV1UsersUsernameSessionsId.ts";
2930 export { route as getApiV1AdminAccounts } from "./getApiV1AdminAccounts.ts";
@@ -126,6 +127,7 @@ export { route as getApiV1UserAssigned } from "./getApiV1UserAssigned.ts";
126127 export { route as getApiV1UserKeys } from "./getApiV1UserKeys.ts";
127128 export { route as getApiV1UserOrgs } from "./getApiV1UserOrgs.ts";
128129 export { route as getApiV1UserOriginApps } from "./getApiV1UserOriginApps.ts";
130 export { route as getApiV1UserOrigins } from "./getApiV1UserOrigins.ts";
129131 export { route as getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath } from "./getApiV1UserOriginsIdBrowseOwnerNameBlobRefPath.ts";
130132 export { route as getApiV1UserOriginsIdBrowseOwnerNameBranches } from "./getApiV1UserOriginsIdBrowseOwnerNameBranches.ts";
131133 export { route as getApiV1UserOriginsIdBrowseOwnerNameCommits } from "./getApiV1UserOriginsIdBrowseOwnerNameCommits.ts";
@@ -141,6 +143,8 @@ export { route as getApiV1UserOriginsIdBrowseOwnerNameTreeRef } from "./getApiV1
141143 export { route as getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath } from "./getApiV1UserOriginsIdBrowseOwnerNameTreeRefPath.ts";
142144 export { route as getApiV1UserOriginsIdMirrorJob } from "./getApiV1UserOriginsIdMirrorJob.ts";
143145 export { route as getApiV1UserOriginsIdMirrorJobStream } from "./getApiV1UserOriginsIdMirrorJobStream.ts";
146 export { route as getApiV1UserOriginsIdRepos } from "./getApiV1UserOriginsIdRepos.ts";
147 export { route as getApiV1UserOriginsOauthId } from "./getApiV1UserOriginsOauthId.ts";
144148 export { route as getApiV1UserPins } from "./getApiV1UserPins.ts";
145149 export { route as getApiV1UsersUsername } from "./getApiV1UsersUsername.ts";
146150 export { route as getApiV1UsersUsernameRepos } from "./getApiV1UsersUsernameRepos.ts";
@@ -234,9 +238,12 @@ export { route as postApiV1Tokens } from "./postApiV1Tokens.ts";
234238 export { route as postApiV1UserEmailVerify } from "./postApiV1UserEmailVerify.ts";
235239 export { route as postApiV1UserKeys } from "./postApiV1UserKeys.ts";
236240 export { route as postApiV1UserOriginApps } from "./postApiV1UserOriginApps.ts";
241 export { route as postApiV1UserOrigins } from "./postApiV1UserOrigins.ts";
237242 export { route as postApiV1UserOriginsIdBrowseOwnerNameContribute } from "./postApiV1UserOriginsIdBrowseOwnerNameContribute.ts";
238243 export { route as postApiV1UserOriginsIdFork } from "./postApiV1UserOriginsIdFork.ts";
239244 export { route as postApiV1UserOriginsIdMirror } from "./postApiV1UserOriginsIdMirror.ts";
245 export { route as postApiV1UserOriginsIdSync } from "./postApiV1UserOriginsIdSync.ts";
246 export { route as postApiV1UserOriginsOauth } from "./postApiV1UserOriginsOauth.ts";
240247 export { route as postApiV1UserPassword } from "./postApiV1UserPassword.ts";
241248 export { route as postApiV1Users } from "./postApiV1Users.ts";
242249 export { route as postMcp } from "./postMcp.ts";
src/contract/openapi/routes/postApiV1UserOrigins.ts added +23
@@ -0,0 +1,23 @@
1 import type { Route } from "../shared.ts";
2
3 const U = "no valid credentials were sent";
4 const F = "the credential is not the account: a session from another origin, or a token without admin scope";
5
6 export const route: Route = {
7 method: "post",
8 path: "/api/v1/user/origins",
9 summary: "Connect a forge with a personal access token; answers 201 with the origin and `reattached`, the frozen mirrors this brought back and the ones it could not, each with its reason; `host` is a name (github), a host or a URL and defaults to `kind`; the account only (session, password or admin-scope token); the token is stored sealed and never read back",
10 auth: true,
11 status: 201,
12 body: { kind: "string", host: "string", token: "string" },
13 refusals: {
14 unauthorized: U,
15 forbidden: F,
16 unknown_host: "422, that does not look like a forge host",
17 private_host: "422, that address is inside a network, not on the internet",
18 unreachable: "422, that host could not be reached",
19 bad_token: "422, the token is empty or the origin rejected it",
20 already_connected: "422, that account is already connected here",
21 rate_limited: "429, too many connect attempts; wait a minute",
22 },
23 };
src/contract/openapi/routes/postApiV1UserOriginsIdSync.ts added +13
@@ -0,0 +1,13 @@
1 import type { Route } from "../shared.ts";
2
3 const U = "no valid credentials were sent";
4 const F = "the credential is not the account: a session from another origin, or a token without admin scope";
5 const NF = "no such origin among the caller's";
6
7 export const route: Route = {
8 method: "post",
9 path: "/api/v1/user/origins/{id}/sync",
10 summary: "Sync an origin's mirrors now; answers the sync report; the account only",
11 auth: true,
12 refusals: { unauthorized: U, forbidden: F, not_found: NF },
13 };
src/contract/openapi/routes/postApiV1UserOriginsOauth.ts added +23
@@ -0,0 +1,23 @@
1 import type { Route } from "../shared.ts";
2
3 const U = "no valid credentials were sent";
4 const F = "the credential is not the account: a session from another origin, or a token without admin scope";
5
6 export const route: Route = {
7 method: "post",
8 path: "/api/v1/user/origins/oauth",
9 summary: "Start connecting a forge by OAuth without handing over a token; answers 201 with { flowId, flow, userCode, url, shortCode, intervalSecs, expiresAt }: flow `device` (GitHub: a person enters `userCode` at `url`) or `code` (Forgejo, Gitea: a person opens `url`); then poll GET /api/v1/user/origins/oauth/{id} with the flowId; the account only",
10 auth: true,
11 status: 201,
12 body: { kind: "string", host: "string" },
13 refusals: {
14 unauthorized: U,
15 forbidden: F,
16 unknown_host: "422, that does not look like a forge host",
17 private_host: "422, that address is inside a network, not on the internet",
18 unreachable: "422, that host could not be reached",
19 no_app: "422, no OAuth application is registered for that host: register one at /api/v1/user/origin-apps, or connect with a token",
20 provider_refused: "422, the forge refused to start the flow",
21 rate_limited: "429, too many connect attempts; wait a minute",
22 },
23 };
test/openapi-coverage.test.ts −7
@@ -11,7 +11,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [
1111 "DELETE /api/v1/orgs/{}/teams/{}/members/{}",
1212 "DELETE /api/v1/repos/{}/{}/labels/{}",
1313 "DELETE /api/v1/user/keys/{}",
14 "DELETE /api/v1/user/origins/{}",
1514 "DELETE /api/v1/user/pins/{}/{}",
1615 "GET /api/v1/agents",
1716 "GET /api/v1/asks",
@@ -33,15 +32,12 @@ const NOT_YET_DESCRIBED: readonly string[] = [
3332 "GET /api/v1/user/assigned",
3433 "GET /api/v1/user/keys",
3534 "GET /api/v1/user/orgs",
36 "GET /api/v1/user/origins",
37 "GET /api/v1/user/origins/oauth/{}",
3835 "GET /api/v1/user/origins/{}/browse/{}/{}",
3936 "GET /api/v1/user/origins/{}/browse/{}/{}/issues",
4037 "GET /api/v1/user/origins/{}/browse/{}/{}/issues/{}",
4138 "GET /api/v1/user/origins/{}/browse/{}/{}/pulls",
4239 "GET /api/v1/user/origins/{}/browse/{}/{}/pulls/{}",
4340 "GET /api/v1/user/origins/{}/mirror/{}/stream",
44 "GET /api/v1/user/origins/{}/repos",
4541 "GET /api/v1/user/pins",
4642 "GET /api/v1/users/{}",
4743 "GET /api/v1/users/{}/repos",
@@ -65,9 +61,6 @@ const NOT_YET_DESCRIBED: readonly string[] = [
6561 "POST /api/v1/repos/{}/{}/reports/{}/dismiss",
6662 "POST /api/v1/repos/{}/{}/runs/{}/steer",
6763 "POST /api/v1/user/keys",
68 "POST /api/v1/user/origins",
69 "POST /api/v1/user/origins/oauth",
70 "POST /api/v1/user/origins/{}/sync",
7164 "PUT /api/v1/front",
7265 "PUT /api/v1/repos/{}/{}/crew/{}",
7366 "PUT /api/v1/repos/{}/{}/pulls/{}/labels",
test/openapi.test.ts +18
@@ -345,6 +345,24 @@ test("mirroring and forking from an origin, its job and stream, and origin apps
345345 expect(row.summary).toContain("mirror.done");
346346 });
347347
348 test("the caller's origins are described, from listing to removal, with no token in the document", () => {
349 const doc = openapi("https://f.test", "1") as any;
350 const O = "/api/v1/user/origins";
351 const want: Record<string, string[]> = {
352 [O]: ["get", "post"],
353 [`${O}/oauth`]: ["post"],
354 [`${O}/oauth/{id}`]: ["get"],
355 [`${O}/{id}`]: ["delete"],
356 [`${O}/{id}/sync`]: ["post"],
357 [`${O}/{id}/repos`]: ["get"],
358 };
359 for (const [path, methods] of Object.entries(want))
360 for (const m of methods) expect(doc.paths[path]?.[m], `${m} ${path}`).toBeDefined();
361 expect(doc.paths[O].post.responses["201"]).toBeDefined();
362 expect(doc.paths[`${O}/oauth`].post.responses["201"]).toBeDefined();
363 expect(JSON.stringify(doc.paths[O])).not.toMatch(/ghp_|github_pat_/);
364 });
365
348366 test("the admin's writes are described: a capacity setting, a limit and a team", () => {
349367 const doc = openapi("https://f.test", "1") as any;
350368 for (const path of ["capacity/{key}", "limits/{key}", "teams/{slug}"]) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs