Public: anyone can read this; the crew can change it.
The OpenAPI document describes the fork tree: following, upstream, sync, make it yours and family #4880
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4871-the-openapi-document-describes-the-fork → main 1 commit fork
2 files
changed +166
−0
src/contract/openapi.ts +138
| @@ -121,6 +121,144 @@ export const ROUTES: readonly Route[] = [ | ||
| 121 | 121 | showProvenance: "boolean", |
| 122 | 122 | }, |
| 123 | 123 | }, |
| 124 | { | |
| 125 | method: "get", | |
| 126 | path: `${REPO}/following`, | |
| 127 | summary: | |
| 128 | "A fork's standing against its parent, read from git: { chain, severedAt, upstream, parent, parentBranch, parentFrozen, branch, state, behind, ahead, clean, conflicts, forkTip, parentTip, parentForge, converge }; a repository that only follows across forges answers { chain, severedAt, upstream }", | |
| 129 | auth: false, | |
| 130 | refusals: { not_a_fork: "404: this repository follows nothing" }, | |
| 131 | }, | |
| 132 | { | |
| 133 | method: "get", | |
| 134 | path: `${REPO}/following/proposals`, | |
| 135 | summary: "How each change proposed to the parent forge stands, as the parent last said; answers { items }", | |
| 136 | auth: false, | |
| 137 | }, | |
| 138 | { | |
| 139 | method: "post", | |
| 140 | path: `${REPO}/following/converge`, | |
| 141 | summary: "Ask the crew to converge a diverged fork with its parent; answers { kind, issue, pull, agent } (202 when queued, 200 when working or proposed)", | |
| 142 | auth: true, | |
| 143 | refusals: { | |
| 144 | not_a_fork: "404: this repository follows nothing", | |
| 145 | forbidden: "403: only someone who may direct the crew", | |
| 146 | archived: "409: the repository is archived", | |
| 147 | ceiling: "429: the crew is at its ceiling", | |
| 148 | }, | |
| 149 | }, | |
| 150 | { | |
| 151 | method: "post", | |
| 152 | path: `${REPO}/following/propose`, | |
| 153 | summary: "Open a pull on the parent with what the fork is ahead by; answers { number, opened, changes, title }", | |
| 154 | auth: true, | |
| 155 | refusals: { | |
| 156 | not_a_fork: "404: this repository follows nothing", | |
| 157 | nothing_to_propose: "409: nothing here is ahead of the parent", | |
| 158 | refused: "409: the parent refused, with the reason", | |
| 159 | }, | |
| 160 | }, | |
| 161 | { | |
| 162 | method: "post", | |
| 163 | path: `${REPO}/following/propose-parent`, | |
| 164 | summary: "Across forges: push this forge's change to the parent forge and open the pull there; answers 201 when opened, 200 when one stands", | |
| 165 | auth: true, | |
| 166 | refusals: { | |
| 167 | no_parent_forge: "404: this repository has no parent forge to propose to", | |
| 168 | nothing_to_propose: "409: nothing here is ahead of the parent forge", | |
| 169 | parent_unreachable: "502: the parent forge did not answer", | |
| 170 | refused: "409: the parent forge refused, with the reason", | |
| 171 | }, | |
| 172 | }, | |
| 173 | { | |
| 174 | method: "post", | |
| 175 | path: `${REPO}/following/sever`, | |
| 176 | summary: "Stop following the parent forge; answers { severedAt }", | |
| 177 | auth: true, | |
| 178 | refusals: { not_following: "404: this repository follows nothing across forges" }, | |
| 179 | }, | |
| 180 | { | |
| 181 | method: "post", | |
| 182 | path: `${REPO}/following/take`, | |
| 183 | summary: | |
| 184 | "Take the parent's updates: a fork merges (the same answer as sync); the repository carrying this forge's code takes as a pull", | |
| 185 | auth: true, | |
| 186 | refusals: { | |
| 187 | not_a_fork: "404: this repository follows nothing", | |
| 188 | not_following: "404: this repository follows nothing across forges", | |
| 189 | conflict: "409: both sides changed these files; the crew was told; { files, converge, issue }", | |
| 190 | moved: "409: something moved while this was read; try again", | |
| 191 | }, | |
| 192 | }, | |
| 193 | { | |
| 194 | method: "patch", | |
| 195 | path: "/api/v1/forge/following", | |
| 196 | summary: "Choose how this forge follows the forge its code came from (its code repository's admin); answers { mode }", | |
| 197 | auth: true, | |
| 198 | body: { mode: "string" }, | |
| 199 | refusals: { | |
| 200 | not_following: "404: this repository follows nothing across forges", | |
| 201 | forbidden: "403: only this forge's owner can change how it follows", | |
| 202 | invalid_mode: "422: mode is not one the forge knows", | |
| 203 | severed: "409: this forge is severed and follows nothing", | |
| 204 | }, | |
| 205 | }, | |
| 206 | { | |
| 207 | method: "get", | |
| 208 | path: `${REPO}/upstream`, | |
| 209 | summary: "The outside upstream this repository follows, for an admin: { following } (null when it follows none)", | |
| 210 | auth: true, | |
| 211 | }, | |
| 212 | { | |
| 213 | method: "post", | |
| 214 | path: `${REPO}/upstream/check`, | |
| 215 | summary: "Check the followed upstream now (admin scope); answers the check's result", | |
| 216 | auth: true, | |
| 217 | refusals: { busy: "409: a check is already running", invalid_url: "422: not an https or http git address" }, | |
| 218 | }, | |
| 219 | { | |
| 220 | method: "post", | |
| 221 | path: `${REPO}/sync`, | |
| 222 | summary: "Sync a mirror with its origin, or let a fork take its parent's updates (a fork needs a writer)", | |
| 223 | auth: true, | |
| 224 | refusals: { | |
| 225 | not_a_fork: "404: this repository follows nothing", | |
| 226 | conflict: "409: both sides changed these files; the crew was told", | |
| 227 | moved: "409: something moved while this was read; try again", | |
| 228 | }, | |
| 229 | }, | |
| 230 | { | |
| 231 | method: "post", | |
| 232 | path: `${REPO}/make-it-yours`, | |
| 233 | summary: | |
| 234 | "Take an app as your own: a fork with its app; a dry run (the default, ro scope) answers the plan, dryRun false (rw scope) answers 201 with the plan and `url`", | |
| 235 | auth: true, | |
| 236 | body: { dryRun: "boolean", name: "string", visibility: "string" }, | |
| 237 | refusals: { | |
| 238 | invalid: "422: visibility is public or private", | |
| 239 | own: "409: this app is already yours", | |
| 240 | unverified: "403: prove your email first", | |
| 241 | limit: "429: at most so many copies a day; { resetsAt }", | |
| 242 | repository_limit: "403: at the repository limit", | |
| 243 | storage_limit: "403: at the storage limit", | |
| 244 | not_member: "403: not a member", | |
| 245 | }, | |
| 246 | }, | |
| 247 | { | |
| 248 | method: "post", | |
| 249 | path: `${REPO}/origin/reconnect`, | |
| 250 | summary: "Reconnect a frozen mirror through one of your origins (admin scope, repository admin); answers the repository", | |
| 251 | auth: true, | |
| 252 | body: { originId: "string" }, | |
| 253 | }, | |
| 254 | { method: "get", path: `${REPO}/forks`, summary: "List the forks of a repository you can read", auth: false }, | |
| 255 | { method: "get", path: `${REPO}/tags`, summary: "List a repository's tags: [{ name, oid, commit }]", auth: false }, | |
| 256 | { | |
| 257 | method: "get", | |
| 258 | path: "/api/v1/family", | |
| 259 | summary: "The forge's family: { parent, here, children, more }; the parent for anyone, the children for a site admin", | |
| 260 | auth: false, | |
| 261 | }, | |
| 124 | 262 | { method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false }, |
| 125 | 263 | { method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars", auth: false }, |
| 126 | 264 | { |
test/openapi-oa-fork.test.ts added +28
| @@ -0,0 +1,28 @@ | ||
| 1 | /** The fork tree is in the served OpenAPI document. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { openapi } from "../src/contract/openapi.ts"; | |
| 4 | ||
| 5 | const R = "/api/v1/repos/{owner}/{name}"; | |
| 6 | const WANT: readonly [string, string][] = [ | |
| 7 | ["get", `${R}/following`], | |
| 8 | ["get", `${R}/following/proposals`], | |
| 9 | ["post", `${R}/following/converge`], | |
| 10 | ["post", `${R}/following/propose`], | |
| 11 | ["post", `${R}/following/propose-parent`], | |
| 12 | ["post", `${R}/following/sever`], | |
| 13 | ["post", `${R}/following/take`], | |
| 14 | ["patch", "/api/v1/forge/following"], | |
| 15 | ["get", `${R}/upstream`], | |
| 16 | ["post", `${R}/upstream/check`], | |
| 17 | ["post", `${R}/sync`], | |
| 18 | ["post", `${R}/make-it-yours`], | |
| 19 | ["post", `${R}/origin/reconnect`], | |
| 20 | ["get", `${R}/forks`], | |
| 21 | ["get", `${R}/tags`], | |
| 22 | ["get", "/api/v1/family"], | |
| 23 | ]; | |
| 24 | ||
| 25 | test("each fork-tree path and method is in the document", () => { | |
| 26 | const paths = openapi("http://x", "1").paths as Record<string, Record<string, unknown>>; | |
| 27 | for (const [method, path] of WANT) expect(paths[path]?.[method]).toBeDefined(); | |
| 28 | }); |
new above
offline