|
|
@@ -5,1598 +5,23 @@ |
| 5 | 5 |
|
| 6 | 6 |
|
| 7 | 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 8 | 12 |
|
| 9 | 13 |
|
| 10 | |
import { CAPACITY_KEYS } from "../core/capacity.ts"; |
| 11 | |
import { FAMILY_WORDS } from "../actions/family_words.ts"; |
| 12 | |
import { LIMIT_KEYS } from "../settings/limits.ts"; |
| 13 | |
import { ADMIN_WORDS } from "../actions/admin_words.ts"; |
| 14 | |
import { SELF_WAIT } from "../actions/plan_words.ts"; |
| 14 |
import type { Route } from "./openapi/shared.ts"; |
| 15 |
import * as each from "./openapi/routes/index.ts"; |
| 15 | 16 |
|
| 16 | |
interface Route { |
| 17 | |
readonly method: "get" | "post" | "put" | "patch" | "delete"; |
| 18 | |
readonly path: string; |
| 19 | |
readonly summary: string; |
| 20 | |
|
| 21 | |
readonly auth: boolean; |
| 22 | |
|
| 23 | |
readonly body?: Readonly<Record<string, string>>; |
| 24 | |
|
| 25 | |
readonly query?: Readonly<Record<string, string>>; |
| 26 | |
|
| 27 | |
readonly status?: number; |
| 28 | |
|
| 29 | |
readonly reply?: Readonly<Record<string, string>>; |
| 30 | |
|
| 31 | |
readonly refusals?: Readonly<Record<string, string>>; |
| 17 |
|
| 18 |
export const ROUTES: readonly Route[] = Object.values(each); |
| 19 |
|
| 20 |
|
| 21 |
export function operationId(r: Pick<Route, "method" | "path">): string { |
| 22 |
return `${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}`; |
| 32 | 23 |
} |
| 33 | 24 |
|
| 34 | |
|
| 35 | |
const LIST_QUERY = { state: "string", limit: "integer", cursor: "integer", label: "string", author: "string" }; |
| 36 | |
|
| 37 | |
|
| 38 | |
const APP_BODY = { name: "string", owner: "string", description: "string", visibility: "string", agent: "string", pick: "boolean" }; |
| 39 | |
const APP_REFUSALS = { |
| 40 | |
unverified: "the account has not verified its email", |
| 41 | |
app_limit: "the owner is at the app limit", |
| 42 | |
no_such_agent: "`agent` names no one on the crew", |
| 43 | |
slug_taken: "another app already has this name's slug", |
| 44 | |
name_taken: "the owner already has a repository of this name", |
| 45 | |
}; |
| 46 | |
|
| 47 | |
|
| 48 | |
const LABEL_REFUSALS = { |
| 49 | |
unknown_label: "a name or id in the set names no label of the repository", |
| 50 | |
forbidden: "the caller cannot write to the repository", |
| 51 | |
mirrored: "the repository mirrors an origin; do it there", |
| 52 | |
origin_owner_only: "only the person who connected the origin can write through it", |
| 53 | |
origin_unreachable: "the origin could not be reached (502)", |
| 54 | |
origin_refused: "the origin refused the change (409)", |
| 55 | |
}; |
| 56 | |
|
| 57 | |
const REPO = "/api/v1/repos/{owner}/{name}"; |
| 58 | |
|
| 59 | |
|
| 60 | |
const BROWSE = "/api/v1/user/origins/{id}/browse/{owner}/{name}"; |
| 61 | |
const BROWSE_LIST_QUERY = { state: "string", label: "string", author: "string", q: "string", cursor: "string" }; |
| 62 | |
const BROWSE_REFUSALS = { |
| 63 | |
unauthorized: "401: no signed-in account", |
| 64 | |
forbidden: "403: a cross-origin browser request, or a token without admin scope", |
| 65 | |
not_found: "404: no such origin of yours, or the origin has no such repository, issue or pull", |
| 66 | |
rate_limited: "429: the origin is rate-limiting this forge", |
| 67 | |
origin_unauthorized: "502: the origin refused this forge's credential; reconnect it", |
| 68 | |
origin_unreachable: "502: the origin could not be reached", |
| 69 | |
}; |
| 70 | |
|
| 71 | |
export const ROUTES: readonly Route[] = [ |
| 72 | |
{ method: "get", path: "/api/v1/apps", summary: "List the apps whose repositories you can read; answers { items, next_cursor }", auth: false }, |
| 73 | |
{ |
| 74 | |
method: "post", |
| 75 | |
path: "/api/v1/apps", |
| 76 | |
summary: "Make an app: a repository, its app and a first issue; answers 201 with { repo, app, issue }", |
| 77 | |
auth: true, |
| 78 | |
status: 201, |
| 79 | |
body: APP_BODY, |
| 80 | |
refusals: APP_REFUSALS, |
| 81 | |
}, |
| 82 | |
{ |
| 83 | |
method: "get", |
| 84 | |
path: "/api/v1/admin/accounts", |
| 85 | |
summary: |
| 86 | |
"List every person and agent, newest first (a forge admin's, read scope; anyone else gets 404); answers { items: [{ username, kind, displayName, isAdmin, createdAt, via, signsInThrough, address, repos, apps, spentUsd, suspendedAt, removedAt, verified }], next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", |
| 87 | |
auth: true, |
| 88 | |
query: { limit: "integer", cursor: "string" }, |
| 89 | |
}, |
| 90 | |
{ |
| 91 | |
method: "delete", |
| 92 | |
path: "/api/v1/admin/accounts/{username}", |
| 93 | |
summary: |
| 94 | |
"Remove an account for good (an admin's, admin scope); answers { removed }. With retireOrgs true and no confirm it answers { confirm_required, retire, own } and removes nothing; with both it retires the organizations the person solely owns and answers { removed, retired }, or 500 { error: \"delete_failed\", failed, removed }", |
| 95 | |
auth: true, |
| 96 | |
body: { retireOrgs: "boolean", confirm: "boolean" }, |
| 97 | |
refusals: { |
| 98 | |
not_allowed: "an admin cannot remove themselves, another admin or an agent (422)", |
| 99 | |
last_owner: "the person is the only one owning an organization (409)", |
| 100 | |
}, |
| 101 | |
}, |
| 102 | |
{ |
| 103 | |
method: "post", |
| 104 | |
path: "/api/v1/admin/accounts/{username}/role", |
| 105 | |
summary: "Make a person a member (with an optional starter allowance in dollars a month) or a guest again (admin scope); answers the new role", |
| 106 | |
auth: true, |
| 107 | |
body: { role: "string", usdPerMonth: "number" }, |
| 108 | |
refusals: { |
| 109 | |
invalid: "`role` is not member or guest, or `usdPerMonth` is not from 0.01 to 10000 dollars (422)", |
| 110 | |
not_allowed: "the person is an admin (422)", |
| 111 | |
}, |
| 112 | |
}, |
| 113 | |
{ |
| 114 | |
method: "post", |
| 115 | |
path: "/api/v1/admin/accounts/{username}/ceiling", |
| 116 | |
summary: "Set an account's daily and per-run model spend ceilings in dollars, both null to take them back (admin scope); answers the ceilings", |
| 117 | |
auth: true, |
| 118 | |
body: { dailyUsd: "number", perRunUsd: "number" }, |
| 119 | |
refusals: { invalid: "an amount is not null or from 0.01 to 10000 dollars (422)" }, |
| 120 | |
}, |
| 121 | |
{ |
| 122 | |
method: "post", |
| 123 | |
path: "/api/v1/admin/accounts/{username}/model-grant", |
| 124 | |
summary: "Grant a person a capped model allowance in dollars a month; null or 0 takes it back (admin scope); answers the grant", |
| 125 | |
auth: true, |
| 126 | |
body: { usdPerMonth: "number" }, |
| 127 | |
refusals: { |
| 128 | |
invalid: "`usdPerMonth` is not null or from 0.01 to 10000 dollars (422)", |
| 129 | |
not_allowed: "the person is an admin (422)", |
| 130 | |
}, |
| 131 | |
}, |
| 132 | |
{ |
| 133 | |
method: "post", |
| 134 | |
path: "/api/v1/admin/accounts/{username}/suspend", |
| 135 | |
summary: "Suspend an account, undone by unsuspend (admin scope); answers { username, suspendedAt }", |
| 136 | |
auth: true, |
| 137 | |
body: { confirm: "boolean" }, |
| 138 | |
refusals: { |
| 139 | |
not_allowed: "an admin cannot suspend themselves, another admin or an agent (422)", |
| 140 | |
last_owner: "the person is the only active owner of an organization; send confirm true to suspend anyway (409)", |
| 141 | |
}, |
| 142 | |
}, |
| 143 | |
{ |
| 144 | |
method: "post", |
| 145 | |
path: "/api/v1/admin/accounts/{username}/unsuspend", |
| 146 | |
summary: "Lift an account's suspension (admin scope); answers { username, suspendedAt: null }", |
| 147 | |
auth: true, |
| 148 | |
refusals: { not_allowed: "the target is an admin, an agent or yourself (422)" }, |
| 149 | |
}, |
| 150 | |
{ |
| 151 | |
method: "post", |
| 152 | |
path: "/api/v1/admin/accounts/{username}/verify", |
| 153 | |
summary: "Mark an account's email verified by hand (admin scope); answers { username, verified: true }", |
| 154 | |
auth: true, |
| 155 | |
refusals: { not_allowed: "the target is an admin, an agent or yourself (422)" }, |
| 156 | |
}, |
| 157 | |
{ |
| 158 | |
method: "get", |
| 159 | |
path: "/api/v1/admin/invites", |
| 160 | |
summary: "List invite links without their tokens (an admin's, read scope); answers { items, next_cursor }; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", |
| 161 | |
auth: true, |
| 162 | |
query: { limit: "integer", cursor: "string" }, |
| 163 | |
}, |
| 164 | |
{ |
| 165 | |
method: "post", |
| 166 | |
path: "/api/v1/admin/invites", |
| 167 | |
summary: |
| 168 | |
"Mint an invite link (admin scope); answers 201 with the invite and its `token` and `link`: the link is shown once, here, and cannot be read again, so keep it", |
| 169 | |
auth: true, |
| 170 | |
status: 201, |
| 171 | |
body: { days: "integer" }, |
| 172 | |
refusals: { invalid: "`days` is not a whole number from 1 to 30 (422)" }, |
| 173 | |
}, |
| 174 | |
{ |
| 175 | |
method: "delete", |
| 176 | |
path: "/api/v1/admin/invites/{id}", |
| 177 | |
summary: "Revoke an invite link by id (admin scope); 404 when there is none", |
| 178 | |
auth: true, |
| 179 | |
}, |
| 180 | |
{ method: "get", path: "/api/v1/explore", summary: "Explore apps and repositories; `q` takes words and `kind:`/`owner:` filters; answers { query, items }", auth: false, query: { q: "string" } }, |
| 181 | |
{ method: "get", path: "/api/v1/search", summary: "Search issues, pulls, repositories and code; `kind` narrows it", auth: false, query: { q: "string", kind: "string" } }, |
| 182 | |
{ |
| 183 | |
method: "post", |
| 184 | |
path: "/api/v1/feed/tells", |
| 185 | |
summary: "Tell an app's agents what you want, from a card or any harness (rw scope); `app` is owner/name, `text` 1 to 2000 characters, `variants` 1 to 4 tries (default 1), `from` the forge path the tell was sent from (a leading single /, at most 512 characters, no spaces); answers 201 with { issue } and, for variants, { issue, issues, assigned, variantGroup }", |
| 186 | |
auth: true, |
| 187 | |
body: { app: "string", text: "string", variants: "integer", from: "string" }, |
| 188 | |
status: 201, |
| 189 | |
refusals: { |
| 190 | |
invalid_text: "`text` is empty or over 2000 characters (422)", |
| 191 | |
invalid_variants: "`variants` is not a whole number from 1 to 4 (400)", |
| 192 | |
invalid_from: "`from` is not a same-forge path of at most 512 characters (400)", |
| 193 | |
not_found: "`app` names no repository the caller can read (404)", |
| 194 | |
forbidden: "the caller may not direct the crew (403)", |
| 195 | |
unverified: "the account has not verified its email (403)", |
| 196 | |
refused: "the tell could not be told (403 or 422)", |
| 197 | |
}, |
| 198 | |
}, |
| 199 | |
{ method: "get", path: "/api/v1/repos", summary: "List public repositories", auth: false }, |
| 200 | |
{ |
| 201 | |
method: "post", |
| 202 | |
path: "/api/v1/repos", |
| 203 | |
summary: |
| 204 | |
"Make a repository under you or an organisation you can write to (rw scope; an agent owns none); `visibility` is public, anything else private; answers 201 with the repository", |
| 205 | |
auth: true, |
| 206 | |
status: 201, |
| 207 | |
body: { name: "string", owner: "string", visibility: "string", description: "string" }, |
| 208 | |
refusals: { |
| 209 | |
invalid_name: "422: not a name a repository may have", |
| 210 | |
name_taken: "422: the owner already has a repository of this name", |
| 211 | |
forbidden_owner: "403: `owner` is not you or an organisation you can write to, or the caller is an agent", |
| 212 | |
repository_limit: "403: at the repository limit", |
| 213 | |
storage_limit: "403: at the storage limit", |
| 214 | |
unverified: "403: the account has not verified its email", |
| 215 | |
}, |
| 216 | |
}, |
| 217 | |
{ |
| 218 | |
method: "get", |
| 219 | |
path: REPO, |
| 220 | |
summary: |
| 221 | |
"Read one repository; `provenance` is where a migrated one came from ({ source, sourceUrl, parents: [{ fullName, url }], migratedAt }), null when it was not migrated or its owner hid it; `upstream` is the stored follow check ({ repo, behind, checkedAt, conflict }), shown to an admin of the repository, null when it follows nothing or to anyone else", |
| 222 | |
auth: false, |
| 223 | |
refusals: { not_found: "404: no such repository, or it is not readable by the caller" }, |
| 224 | |
}, |
| 225 | |
{ |
| 226 | |
method: "post", |
| 227 | |
path: `${REPO}/reports`, |
| 228 | |
summary: "Report a problem in a public repository, without an account", |
| 229 | |
auth: false, |
| 230 | |
body: { title: "string", body: "string", page: "string" }, |
| 231 | |
}, |
| 232 | |
{ |
| 233 | |
method: "get", |
| 234 | |
path: "/api/v1/admin/settings", |
| 235 | |
summary: |
| 236 | |
"Read the forge's signup mode and account quotas (a person admin's token, any scope); answers { quotas, signup, signupChanges, changes }", |
| 237 | |
auth: true, |
| 238 | |
refusals: { not_found: "the token is not a person who administers the forge" }, |
| 239 | |
}, |
| 240 | |
{ |
| 241 | |
method: "patch", |
| 242 | |
path: "/api/v1/admin/settings", |
| 243 | |
summary: |
| 244 | |
"Change the signup mode (open, invite or closed) or the account quotas, one of the two per call (a person admin's token, admin scope); answers { signup } or { quotas }; a quota is a positive whole number, or null for no limit", |
| 245 | |
auth: true, |
| 246 | |
body: { signup: "string", quotas: "object" }, |
| 247 | |
refusals: { |
| 248 | |
invalid_body: "neither signup nor quotas was sent, or both were", |
| 249 | |
invalid_signup: "signup is not open, invite or closed", |
| 250 | |
no_such_quota: "a quota key is unknown", |
| 251 | |
invalid_quota: "a quota is not a positive whole number or null", |
| 252 | |
not_found: "the token is not a person who administers the forge", |
| 253 | |
}, |
| 254 | |
}, |
| 255 | |
{ |
| 256 | |
method: "get", |
| 257 | |
path: "/api/v1/admin/limits", |
| 258 | |
summary: |
| 259 | |
"Read the forge's limits (a person admin's token, any scope); answers { items: [{ key, value, isSet, defaultValue, ... }], changes, next_cursor }", |
| 260 | |
auth: true, |
| 261 | |
refusals: { not_found: "the token is not a person who administers the forge" }, |
| 262 | |
}, { |
| 263 | |
method: "put", |
| 264 | |
path: "/api/v1/admin/limits/{key}", |
| 265 | |
summary: `Change one limit live, or send value null to take it back to its default (a person admin's token, admin scope); {key} is one of ${LIMIT_KEYS.join(", ")}; answers the changed limit`, |
| 266 | |
auth: true, |
| 267 | |
body: { value: "integer or null" }, |
| 268 | |
refusals: { |
| 269 | |
not_found: "the token is not a person who administers the forge (404)", |
| 270 | |
no_such_limit: "{key} is not a limit (422)", |
| 271 | |
out_of_bounds: "the value is outside the limit's bounds, named in the message (422)", |
| 272 | |
}, |
| 273 | |
}, |
| 274 | |
|
| 275 | |
{ |
| 276 | |
method: "get", |
| 277 | |
path: "/api/v1/admin/capacity", |
| 278 | |
summary: |
| 279 | |
"Read the forge's capacity settings (a person admin's token, any scope); answers { items: [{ key, label, help, value, source, default, env, changed? }], changes, next_cursor }", |
| 280 | |
auth: true, |
| 281 | |
refusals: { not_found: "the token is not a person who administers the forge" }, |
| 282 | |
}, { |
| 283 | |
method: "put", |
| 284 | |
path: "/api/v1/admin/capacity/{key}", |
| 285 | |
summary: `Change one capacity setting live, or send value null to take it back to its default (a person admin's token, admin scope); {key} is one of ${CAPACITY_KEYS.join(", ")}; answers the changed setting`, |
| 286 | |
auth: true, |
| 287 | |
body: { value: "string or null" }, |
| 288 | |
refusals: { |
| 289 | |
not_found: "the token is not a person who administers the forge (404)", |
| 290 | |
no_such_setting: "{key} is not a capacity setting (422)", |
| 291 | |
invalid_value: "the value is outside what this setting takes (422)", |
| 292 | |
no_credential: "the setting needs a credential the forge does not hold (422)", |
| 293 | |
}, |
| 294 | |
}, |
| 295 | |
|
| 296 | |
{ |
| 297 | |
method: "get", |
| 298 | |
path: "/api/v1/admin/teams", |
| 299 | |
summary: |
| 300 | |
"Read the crew's teams (a person admin's token, any scope); answers { teams: [{ slug, charter, dial, members, ... }] }", |
| 301 | |
auth: true, |
| 302 | |
refusals: { not_found: "the token is not a person who administers the forge" }, |
| 303 | |
}, { |
| 304 | |
method: "put", |
| 305 | |
path: "/api/v1/admin/teams/{slug}", |
| 306 | |
summary: "Change a crew team's charter, dial or members; send only what changes (a person admin's token, admin scope); answers the team", |
| 307 | |
auth: true, |
| 308 | |
body: { charter: "string", dial: "integer", members: "array of agent ids" }, |
| 309 | |
refusals: { |
| 310 | |
invalid_body: "charter is not text, dial not a number, or members not a list of agent ids (422)", |
| 311 | |
not_found: "no such team, or the token is not a person who administers the forge (404)", |
| 312 | |
bad_dial: "dial is not a whole number in range (422)", |
| 313 | |
bad_charter: "the charter is too long (422)", |
| 314 | |
over_100: "the teams' dials together would pass 100% (422)", |
| 315 | |
bad_member: "a member is not an agent on this forge (422)", |
| 316 | |
}, |
| 317 | |
}, |
| 318 | |
|
| 319 | |
{ |
| 320 | |
method: "get", |
| 321 | |
path: "/api/v1/admin/people", |
| 322 | |
summary: |
| 323 | |
"List every person and their role (admin scope); answers { items: [{ username, role, usdPerMonth }], next_cursor }, role being admin, member or guest; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", |
| 324 | |
auth: true, |
| 325 | |
query: { limit: "integer", cursor: "string" }, |
| 326 | |
}, |
| 327 | |
{ method: "get", path: `${REPO}/issues`, summary: "List a repository's issues; fields=brief leaves out bodies and comments, any other fields value is a 422 invalid_fields", auth: false, query: { ...LIST_QUERY, fields: "string" } }, |
| 328 | |
{ method: "get", path: `${REPO}/pulls`, summary: "List a repository's pulls", auth: false, query: LIST_QUERY }, |
| 329 | |
{ |
| 330 | |
method: "get", |
| 331 | |
path: `${REPO}/branches`, |
| 332 | |
summary: |
| 333 | |
"List a repository's branches; answers { items: [{ name, oid, isDefault, ahead, behind, protected }], next_cursor }; ahead and behind are null past the comparison cap; limit 1 to 200, cursor from next_cursor; a private repository needs a token", |
| 334 | |
auth: false, |
| 335 | |
query: { limit: "integer", cursor: "string" }, |
| 336 | |
refusals: { |
| 337 | |
invalid_limit: "limit is not a whole number from 1 to 200 (422)", |
| 338 | |
invalid_cursor: "cursor is not the next_cursor of a page before (422)", |
| 339 | |
}, |
| 340 | |
}, |
| 341 | |
{ method: "get", path: `${REPO}/commits`, summary: "List commits newest first from `ref` (default: the default branch) as a bare array of { oid, parents, author, email, date, subject, body }; limit 1 to 100 (default 30); an unsafe ref is a 422 bad_ref; a private repository needs a token", auth: false, query: { ref: "string", limit: "integer" } }, |
| 342 | |
{ method: "get", path: `${REPO}/commits/{oid}`, summary: "Read one commit (a full or abbreviated id, or a ref) as { oid, parents, author, email, date, subject, body, diff: { files, additions, deletions } }; a private repository needs a token", auth: false }, |
| 343 | |
{ method: "get", path: `${REPO}/contents`, summary: "Read the tree or a file at `ref` (default: the default branch); no `path` or a directory answers { type: \"dir\", ref, path, entries }, a file answers { type: \"file\", ref, path, size, encoding: \"utf-8\" | \"base64\", content }, a file over 5 MB is a 413 { type: \"file\", tooLarge: true, size }; a private repository needs a token", auth: false, query: { path: "string", ref: "string" } }, |
| 344 | |
{ method: "get", path: `${REPO}/labels`, summary: "List a repository's labels by name as a bare array of { id, name, color }; a private repository needs a token", auth: false }, |
| 345 | |
{ method: "get", path: `${REPO}/milestones`, summary: "Read the plan: { repo: { owner, name }, limbs }, one limb per milestone with its weights (and askToLive for a pillar's umbrella); a private repository needs a token", auth: false }, |
| 346 | |
{ method: "get", path: `${REPO}/milestones/{number}`, summary: "Read one milestone (a 404 for an issue that waits for nothing) as { repo, ...milestone, askToLive?, watching? }; watching is present only for a signed-in user; a private repository needs a token", auth: false }, |
| 347 | |
{ method: "get", path: `${REPO}/waves`, summary: "List what shipped, newest first, as { items: [{ version, date, headline }], next_cursor: null }; a file that is not a wave is left out; a private repository needs a token", auth: false, reply: { items: "array", next_cursor: "null" }, refusals: { not_found: "404: no such repository, or it is not readable" } }, |
| 348 | |
{ method: "get", path: `${REPO}/waves/{version}`, summary: "Read one wave as { version, date, headline, highlights, pulls: [{ number, title, url }] }; number is null when the line names no pull; a private repository needs a token", auth: false, reply: { version: "string", date: "string", headline: "string", highlights: "array", pulls: "array" }, refusals: { not_found: "404: no such repository, or it is not readable, or no such wave" } }, |
| 349 | |
{ |
| 350 | |
method: "post", |
| 351 | |
path: `${REPO}/issues`, |
| 352 | |
summary: "Open an issue", |
| 353 | |
auth: true, |
| 354 | |
body: { title: "string", body: "string" }, |
| 355 | |
}, |
| 356 | |
{ method: "get", path: `${REPO}/issues/{number}`, summary: "Read one issue with its comments; comments=N (0 to 200) answers only the newest N and commentCount, any other value is a 422 invalid_comments", auth: false, query: { comments: "integer" } }, |
| 357 | |
{ |
| 358 | |
method: "patch", |
| 359 | |
path: `${REPO}/issues/{number}`, |
| 360 | |
summary: "Close or reopen an issue (state, with a reason and the issue it went into)", |
| 361 | |
auth: true, |
| 362 | |
body: { state: "string", reason: "string", into: "integer" }, |
| 363 | |
}, |
| 364 | |
{ |
| 365 | |
method: "get", |
| 366 | |
path: "/api/v1/secrets/{scope}", |
| 367 | |
summary: "List a scope's secret names and timestamps, never a value; admin only; `ref` names an org or `owner/name`, the forge scope takes none; answers { items, next_cursor }", |
| 368 | |
auth: true, |
| 369 | |
query: { ref: "string" }, |
| 370 | |
refusals: { not_found: "the scope's owner is unknown", invalid_scope: "the scope is not one the forge keeps secrets for" }, |
| 371 | |
}, |
| 372 | |
{ |
| 373 | |
method: "put", |
| 374 | |
path: "/api/v1/secrets/{scope}/{name}", |
| 375 | |
summary: "Set a secret by name (admin scope, a site admin's); the value is write-only, the read never returns it; `ref` names an org or `owner/name`; answers the name and timestamps, never the value", |
| 376 | |
auth: true, |
| 377 | |
body: { value: "string", ref: "string" }, |
| 378 | |
refusals: { |
| 379 | |
invalid_name: "`name` is not an acceptable secret name (422)", |
| 380 | |
invalid_value: "`value` is not acceptable (422)", |
| 381 | |
invalid_scope: "the scope is not one the forge keeps secrets for (422)", |
| 382 | |
}, |
| 383 | |
}, |
| 384 | |
{ |
| 385 | |
method: "delete", |
| 386 | |
path: "/api/v1/secrets/{scope}/{name}", |
| 387 | |
summary: "Remove a secret by name (admin scope token); a value is never read back; answers {}", |
| 388 | |
auth: true, |
| 389 | |
query: { ref: "string" }, |
| 390 | |
refusals: { not_found: "no such secret or scope owner", invalid_scope: "the scope is not one the forge keeps secrets for" }, |
| 391 | |
}, |
| 392 | |
{ |
| 393 | |
method: "get", |
| 394 | |
path: "/api/v1/tokens", |
| 395 | |
summary: "List your own tokens; a bearer token is refused with 403, call it from a browser session or with a password", |
| 396 | |
auth: false, |
| 397 | |
refusals: { forbidden: "the caller holds a bearer token, which cannot manage tokens" }, |
| 398 | |
}, |
| 399 | |
{ |
| 400 | |
method: "post", |
| 401 | |
path: "/api/v1/tokens", |
| 402 | |
summary: "Mint a token of scope ro, rw or admin; needs a browser session or a username and password, a bearer token is refused with 403; a site admin must send adminConfirm: true; answers 201 with the token once", |
| 403 | |
auth: false, |
| 404 | |
status: 201, |
| 405 | |
body: { name: "string", scope: "string", username: "string", password: "string", adminConfirm: "boolean" }, |
| 406 | |
refusals: { |
| 407 | |
invalid_scope: "scope is not ro, rw or admin", |
| 408 | |
admin_confirm: "a site admin did not send adminConfirm: true", |
| 409 | |
forbidden: "a bearer token cannot mint tokens, or the account may not hold an admin token", |
| 410 | |
}, |
| 411 | |
}, |
| 412 | |
{ |
| 413 | |
method: "delete", |
| 414 | |
path: "/api/v1/tokens/{id}", |
| 415 | |
summary: "Revoke one of your tokens; a bearer token is refused with 403, call it from a browser session or with a password; answers 204", |
| 416 | |
auth: false, |
| 417 | |
status: 204, |
| 418 | |
refusals: { forbidden: "the caller holds a bearer token, which cannot manage tokens" }, |
| 419 | |
}, |
| 420 | |
{ |
| 421 | |
method: "get", |
| 422 | |
path: "/api/v1/users/{username}/signins", |
| 423 | |
summary: "List where a person is signed in: answers { tokens, sessions }, each token { id, name, scope, createdAt, lastUsedAt, expiresAt } and each live browser session { id, createdAt, lastSeenAt, expiresAt, current }; a session's secret and a token's value are never answered; the person themself or the forge's owner, with a browser session, a password or an admin-scope token", |
| 424 | |
auth: true, |
| 425 | |
refusals: { |
| 426 | |
unauthorized: "no credentials were sent", |
| 427 | |
forbidden: "the caller holds a token below admin scope", |
| 428 | |
not_found: "no such account, or it is not one the caller may see", |
| 429 | |
}, |
| 430 | |
}, |
| 431 | |
{ |
| 432 | |
method: "delete", |
| 433 | |
path: "/api/v1/users/{username}/sessions/{id}", |
| 434 | |
summary: "End one browser session of a person, as when a device is lost; the person themself or the forge's owner, with a browser session, a password or an admin-scope token; answers 204 and never a session secret", |
| 435 | |
auth: true, |
| 436 | |
status: 204, |
| 437 | |
refusals: { |
| 438 | |
unauthorized: "no credentials were sent", |
| 439 | |
forbidden: "the caller holds a token below admin scope", |
| 440 | |
not_found: "no such account or session, or it is not one the caller may end", |
| 441 | |
}, |
| 442 | |
}, |
| 443 | |
{ method: "get", path: "/api/v1/telemetry/system", summary: "Read the forge's system view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true }, |
| 444 | |
{ method: "get", path: "/api/v1/telemetry/flow", summary: "Read the forge's flow view; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true }, |
| 445 | |
{ method: "get", path: "/api/v1/telemetry/home", summary: "Read the figures the home is scored against: landed in 24 h, live version, builders and why idle, spend against the ceiling, what waits on the caller; the owner or an agent holding telemetry:read, with a ro token; every agent read is logged", auth: true }, |
| 446 | |
{ method: "get", path: "/api/v1/telemetry/sources", summary: "List telemetry sources; answers { sources }; the owner or an agent holding telemetry:read", auth: true }, |
| 447 | |
{ method: "get", path: "/api/v1/telemetry/history", summary: "Read telemetry history; answers { points }; the owner or an agent holding telemetry:read", auth: true }, |
| 448 | |
{ method: "get", path: `${REPO}/pulls/{number}`, summary: "Read one pull with its diff and review gate (diff=0 leaves the diff out)", auth: false, query: { diff: "string" } }, |
| 449 | |
{ |
| 450 | |
method: "patch", |
| 451 | |
path: `${REPO}/pulls/{number}`, |
| 452 | |
summary: "Edit a pull's title or body and open or close it (rw scope); answers the pull", |
| 453 | |
auth: true, |
| 454 | |
body: { title: "string", body: "string", state: "string" }, |
| 455 | |
refusals: { |
| 456 | |
invalid_text: "`title` or `body` is not a string (422)", |
| 457 | |
invalid_state: "`state` is neither open nor closed (422)", |
| 458 | |
invalid_reason: "`reason` or `into` is given; a pull has none (422)", |
| 459 | |
nothing_to_change: "none of `title`, `body` and `state` is given (422)", |
| 460 | |
taken_up: "someone who directs the crew has acted on the words, so an agent may not edit them (403)", |
| 461 | |
forbidden: "you may not open or close this pull (403); a planner may close only an agent's pull whose issue is closed", |
| 462 | |
}, |
| 463 | |
}, |
| 464 | |
{ method: "get", path: `${REPO}/issues/{number}/comments`, summary: "Page through an issue's or pull's comments, oldest first: {comments, next}; limit 1 to 100 (default 50), after a comment id", auth: false, query: { after: "string", limit: "integer" } }, |
| 465 | |
{ |
| 466 | |
method: "post", |
| 467 | |
path: `${REPO}/issues/{number}/comments`, |
| 468 | |
summary: "Comment on an issue or pull", |
| 469 | |
auth: true, |
| 470 | |
body: { body: "string" }, |
| 471 | |
}, |
| 472 | |
{ method: "get", path: `${REPO}/runs`, summary: "List a repository's runs, newest first: { items, next_cursor }; kind deploy or agent, env an environment name, limit 1 to 200 (default 50), cursor a run id; a stranger's runs carry no cost, turns, agent session or node", auth: false, query: { kind: "string", env: "string", limit: "integer", cursor: "string" } }, |
| 473 | |
{ method: "get", path: `${REPO}/runs/{id}`, summary: "Read one run as this reader may see it, with `carries`, the merged pulls a finished production deploy carries", auth: false }, |
| 474 | |
{ method: "get", path: `${REPO}/runs/{id}/log`, summary: "Read a run's log as text/plain, one line per line; follow=1 or Accept: text/event-stream tails it live as server-sent events (line, then done); 404 when the log is hidden", auth: false, query: { follow: "string" } }, |
| 475 | |
{ |
| 476 | |
method: "post", |
| 477 | |
path: `${REPO}/runs/{id}/cancel`, |
| 478 | |
summary: "Cancel a run that has not finished (needs rw scope and write access); answers the cancelled run", |
| 479 | |
auth: true, |
| 480 | |
refusals: { finished: "the run has already finished (409)" }, |
| 481 | |
}, |
| 482 | |
{ |
| 483 | |
method: "get", |
| 484 | |
path: `${REPO}/usage`, |
| 485 | |
summary: "What the crew's runs here spent over a window, the owner's own facts: { app, days, since, total, bySubscription, byAgent, byIssue, recent, standing, spend, guard }; days 1 to 90; 403 to a reader who cannot write", |
| 486 | |
auth: true, |
| 487 | |
query: { days: "integer" }, |
| 488 | |
refusals: { invalid_days: "days is not a whole number from 1 to 90" }, |
| 489 | |
}, |
| 490 | |
{ |
| 491 | |
method: "post", |
| 492 | |
path: `${REPO}/spend`, |
| 493 | |
summary: "Report what an outside session spent on this repository's work (needs rw scope; writers and the crew); answers 201 with the record", |
| 494 | |
auth: true, |
| 495 | |
status: 201, |
| 496 | |
body: { who: "string", tokensIn: "integer", tokensOut: "integer", usd: "number", model: "string", note: "string", issue: "integer" }, |
| 497 | |
refusals: { invalid: "who is 1 to 100 characters and some spend is given (tokensIn, tokensOut whole numbers; usd 0 to 10000); note is at most 500" }, |
| 498 | |
}, |
| 499 | |
{ method: "get", path: `${REPO}/events`, summary: "Page through a repository's events as the caller may see them, newest first: {items, next_cursor}; limit 1 to 200 (default 50), cursor from next_cursor; a bad limit or cursor is a 422", auth: false, query: { limit: "integer", cursor: "string" } }, |
| 500 | |
{ method: "post", path: `${REPO}/forks`, summary: "Fork a repository", auth: true }, |
| 501 | |
{ |
| 502 | |
method: "post", |
| 503 | |
path: `${REPO}/pulls`, |
| 504 | |
summary: "Offer a change as a pull", |
| 505 | |
auth: true, |
| 506 | |
body: { title: "string", head: "string", headRepo: "string" }, |
| 507 | |
}, |
| 508 | |
{ method: "get", path: `${REPO}/crew`, summary: "List the repository's crew; answers { items, next_cursor } (names and roles for every reader, brief, model and budget for writers)", auth: false }, |
| 509 | |
{ |
| 510 | |
method: "put", |
| 511 | |
path: `${REPO}/crew/{username}`, |
| 512 | |
summary: "Put an agent on the repository's crew (admin scope, a repository admin's); answers the agent", |
| 513 | |
auth: true, |
| 514 | |
refusals: { |
| 515 | |
no_such_agent: "`username` names no one (422)", |
| 516 | |
not_an_agent: "`username` is a person, not an agent (422)", |
| 517 | |
unverified: "the caller's email is unproven (403)", |
| 518 | |
}, |
| 519 | |
}, |
| 520 | |
{ |
| 521 | |
method: "delete", |
| 522 | |
path: `${REPO}/crew/{username}`, |
| 523 | |
summary: "Take an agent off the repository's crew (admin scope, a repository admin's); answers {}", |
| 524 | |
auth: true, |
| 525 | |
refusals: { no_such_agent: "`username` names no one on the crew" }, |
| 526 | |
}, |
| 527 | |
{ method: "get", path: `${REPO}/prompts`, summary: "List the repository's prompt sources (admin scope); answers { items, next_cursor }, tokens never come back", auth: true }, |
| 528 | |
{ |
| 529 | |
method: "post", |
| 530 | |
path: `${REPO}/prompts`, |
| 531 | |
summary: "Add a prompt source (admin scope); answers 201 with the source", |
| 532 | |
auth: true, |
| 533 | |
status: 201, |
| 534 | |
body: { kind: "string", token: "string", at: "string" }, |
| 535 | |
refusals: { invalid: "`kind`, `token` or `at` is not acceptable (422)" }, |
| 536 | |
}, |
| 537 | |
{ method: "delete", path: `${REPO}/prompts/{id}`, summary: "Remove a prompt source (admin scope); answers {}", auth: true }, |
| 538 | |
{ method: "get", path: `${REPO}/egress-grants`, summary: "List the destinations the app may reach (admin scope); answers { items, changes, next_cursor }", auth: true }, |
| 539 | |
{ |
| 540 | |
method: "post", |
| 541 | |
path: `${REPO}/egress-grants`, |
| 542 | |
summary: "Grant the app one egress destination (admin scope); answers 201 with the grant", |
| 543 | |
auth: true, |
| 544 | |
status: 201, |
| 545 | |
body: { destination: "string" }, |
| 546 | |
refusals: { invalid: "`destination` is not acceptable (422)" }, |
| 547 | |
}, |
| 548 | |
{ |
| 549 | |
method: "delete", |
| 550 | |
path: `${REPO}/egress-grants`, |
| 551 | |
summary: "Revoke one egress destination, named in the body (admin scope); answers {}", |
| 552 | |
auth: true, |
| 553 | |
body: { destination: "string" }, |
| 554 | |
}, |
| 555 | |
{ method: "get", path: `${REPO}/hooks`, summary: "List the repository's webhooks (admin scope); answers an array of webhooks", auth: true }, |
| 556 | |
{ |
| 557 | |
method: "post", |
| 558 | |
path: `${REPO}/hooks`, |
| 559 | |
summary: "Add a webhook (admin scope); answers 201 with the webhook and its `secret`, shown once", |
| 560 | |
auth: true, |
| 561 | |
status: 201, |
| 562 | |
body: { url: "string", kinds: "string", secret: "string" }, |
| 563 | |
refusals: { invalid: "`url` or `kinds` is not acceptable (422)" }, |
| 564 | |
}, |
| 565 | |
{ method: "delete", path: `${REPO}/hooks/{id}`, summary: "Remove a webhook (admin scope); answers {}, 404 when it is not there", auth: true }, |
| 566 | |
{ |
| 567 | |
method: "get", |
| 568 | |
path: `${REPO}/decisions`, |
| 569 | |
summary: "Read the decision ledger; `kind` is next, land, retry or guard; answers { decisions, week }", |
| 570 | |
auth: false, |
| 571 | |
query: { kind: "string" }, |
| 572 | |
}, |
| 573 | |
{ method: "get", path: `${REPO}/pulls/{number}/reviews`, summary: "List a pull's line-anchored review comments, each with its `anchor` and whether it is `outdated`", auth: false }, |
| 574 | |
{ |
| 575 | |
method: "post", |
| 576 | |
path: `${REPO}/pulls/{number}/reviews`, |
| 577 | |
summary: "Comment on one line of a pull's diff (rw scope); answers 201 with the comment; a verdict goes to …/review-states", |
| 578 | |
auth: true, |
| 579 | |
status: 201, |
| 580 | |
body: { path: "string", line: "integer", side: "string", body: "string" }, |
| 581 | |
refusals: { bad_anchor: "`path`, `line` and `side` (old or new) name no line of the pull's current diff (422)" }, |
| 582 | |
}, |
| 583 | |
{ method: "post", path: `${REPO}/pulls/{number}/merge`, summary: "Merge a pull (a person's)", auth: true }, |
| 584 | |
{ method: "get", path: `${REPO}/pulls/{number}/threads`, summary: "List a pull's review threads: every review comment sharing one anchor, and whether the conversation is resolved", auth: false }, |
| 585 | |
{ |
| 586 | |
method: "post", |
| 587 | |
path: `${REPO}/pulls/{number}/threads/{anchor}/resolve`, |
| 588 | |
summary: "Mark a review thread resolved (rw scope; the thread's author or a writer); answers the thread", |
| 589 | |
auth: true, |
| 590 | |
refusals: { |
| 591 | |
not_found: "the pull has no thread at `anchor` (404)", |
| 592 | |
forbidden: "you neither wrote the thread's comments nor can write to the repository (403)", |
| 593 | |
}, |
| 594 | |
}, |
| 595 | |
{ |
| 596 | |
method: "delete", |
| 597 | |
path: `${REPO}/pulls/{number}/threads/{anchor}/resolve`, |
| 598 | |
summary: "Reopen a resolved review thread (rw scope; the thread's author or a writer); answers the thread", |
| 599 | |
auth: true, |
| 600 | |
refusals: { |
| 601 | |
not_found: "the pull has no thread at `anchor` (404)", |
| 602 | |
forbidden: "you neither wrote the thread's comments nor can write to the repository (403)", |
| 603 | |
}, |
| 604 | |
}, |
| 605 | |
{ |
| 606 | |
method: "get", |
| 607 | |
path: `${REPO}/pulls/{number}/checks`, |
| 608 | |
summary: "A pull's checks at its current head; answers { head, state, required, run, runs } with state `none` when no run exists", |
| 609 | |
auth: false, |
| 610 | |
reply: { head: "string", state: "string", required: "boolean", run: "object", runs: "array" }, |
| 611 | |
}, |
| 612 | |
{ |
| 613 | |
method: "post", |
| 614 | |
path: `${REPO}/pulls/{number}/checks`, |
| 615 | |
summary: "Ask for a check again at the pull's head as it is now (rw scope, a writer's); answers 202 with { run }", |
| 616 | |
auth: true, |
| 617 | |
status: 202, |
| 618 | |
reply: { run: "object" }, |
| 619 | |
refusals: { |
| 620 | |
forbidden: "only a writer can ask for a check (403)", |
| 621 | |
not_open: "only an open pull is checked (409)", |
| 622 | |
no_checks: "this forge runs no checks (409)", |
| 623 | |
no_check_declared: "this head declares no check, or waits for a writer's approval (409)", |
| 624 | |
}, |
| 625 | |
}, |
| 626 | |
{ |
| 627 | |
method: "post", |
| 628 | |
path: `${REPO}/pulls/{number}/approve-here`, |
| 629 | |
summary: "Let an origin fork's pull run here: a writer approves its current head (rw scope); answers 201", |
| 630 | |
auth: true, |
| 631 | |
status: 201, |
| 632 | |
refusals: { |
| 633 | |
not_foreign: "the pull needs no approval to run (422)", |
| 634 | |
self_review: "you cannot approve your own pull (422)", |
| 635 | |
not_open: "the pull is closed (422)", |
| 636 | |
}, |
| 637 | |
}, |
| 638 | |
{ |
| 639 | |
method: "put", |
| 640 | |
path: `${REPO}/issues/{number}/assignee`, |
| 641 | |
summary: "Assign an issue to a person or crew member, or to no one with null (rw scope); answers the issue", |
| 642 | |
auth: true, |
| 643 | |
body: { assignee: "string" }, |
| 644 | |
refusals: { |
| 645 | |
invalid_assignee: "`assignee` is neither a username nor null (422)", |
| 646 | |
no_such_user: "`assignee` names no account (422)", |
| 647 | |
}, |
| 648 | |
}, |
| 649 | |
{ |
| 650 | |
method: "put", |
| 651 | |
path: `${REPO}/pulls/{number}/assignee`, |
| 652 | |
summary: "Assign a pull to a person or crew member, or to no one with null (rw scope); answers the pull", |
| 653 | |
auth: true, |
| 654 | |
body: { assignee: "string" }, |
| 655 | |
refusals: { |
| 656 | |
invalid_assignee: "`assignee` is neither a username nor null (422)", |
| 657 | |
no_such_user: "`assignee` names no account (422)", |
| 658 | |
}, |
| 659 | |
}, |
| 660 | |
{ |
| 661 | |
method: "patch", |
| 662 | |
path: REPO, |
| 663 | |
summary: |
| 664 | |
"Change a repository's settings (an admin's); `showProvenance` shows or hides where a migrated one came from (a person's, never an agent's)", |
| 665 | |
auth: true, |
| 666 | |
body: { |
| 667 | |
description: "string", |
| 668 | |
visibility: "string", |
| 669 | |
archived: "boolean", |
| 670 | |
showProvenance: "boolean", |
| 671 | |
defaultBranch: "string", |
| 672 | |
protectDefault: "boolean", |
| 673 | |
requireReview: "boolean", |
| 674 | |
requireChecks: "boolean", |
| 675 | |
crewOutsiders: "boolean", |
| 676 | |
decisionsBy: "string", |
| 677 | |
plansNeedNod: "boolean", |
| 678 | |
}, |
| 679 | |
refusals: { |
| 680 | |
bad_archived: "422: archived is true or false", |
| 681 | |
bad_decisions_by: "422: decisionsBy is me, decider or decider_tell", |
| 682 | |
bad_plans_need_nod: "422: plansNeedNod is true or false", |
| 683 | |
bad_show_provenance: "422: showProvenance is true or false", |
| 684 | |
no_provenance: `422: ${FAMILY_WORDS.no_provenance}`, |
| 685 | |
forbidden: "403: only an admin archives or wakes", |
| 686 | |
not_found: "404: no such repository, or it is not readable by the caller", |
| 687 | |
}, |
| 688 | |
}, |
| 689 | |
{ |
| 690 | |
method: "delete", |
| 691 | |
path: REPO, |
| 692 | |
summary: "Delete a repository for good (admin scope, an admin's); answers {}", |
| 693 | |
auth: true, |
| 694 | |
refusals: { not_found: "404: no such repository, or the caller is not its admin", delete_failed: "500: the repository could not be removed" }, |
| 695 | |
}, |
| 696 | |
{ |
| 697 | |
method: "get", |
| 698 | |
path: `${REPO}/following`, |
| 699 | |
summary: |
| 700 | |
"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 }", |
| 701 | |
auth: false, |
| 702 | |
refusals: { not_a_fork: "404: this repository follows nothing" }, |
| 703 | |
}, |
| 704 | |
{ |
| 705 | |
method: "get", |
| 706 | |
path: `${REPO}/following/proposals`, |
| 707 | |
summary: "How each change proposed to the parent forge stands, as the parent last said; answers { items }", |
| 708 | |
auth: false, |
| 709 | |
}, |
| 710 | |
{ |
| 711 | |
method: "post", |
| 712 | |
path: `${REPO}/following/converge`, |
| 713 | |
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)", |
| 714 | |
auth: true, |
| 715 | |
refusals: { |
| 716 | |
not_a_fork: "404: this repository follows nothing", |
| 717 | |
forbidden: "403: only someone who may direct the crew", |
| 718 | |
archived: "409: the repository is archived", |
| 719 | |
ceiling: "429: the crew is at its ceiling", |
| 720 | |
}, |
| 721 | |
}, |
| 722 | |
{ |
| 723 | |
method: "post", |
| 724 | |
path: `${REPO}/following/propose`, |
| 725 | |
summary: "Open a pull on the parent with what the fork is ahead by; answers { number, opened, changes, title }", |
| 726 | |
auth: true, |
| 727 | |
refusals: { |
| 728 | |
not_a_fork: "404: this repository follows nothing", |
| 729 | |
nothing_to_propose: "409: nothing here is ahead of the parent", |
| 730 | |
refused: "409: the parent refused, with the reason", |
| 731 | |
}, |
| 732 | |
}, |
| 733 | |
{ |
| 734 | |
method: "post", |
| 735 | |
path: `${REPO}/following/propose-parent`, |
| 736 | |
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", |
| 737 | |
auth: true, |
| 738 | |
refusals: { |
| 739 | |
no_parent_forge: "404: this repository has no parent forge to propose to", |
| 740 | |
nothing_to_propose: `409: ${FAMILY_WORDS.nothing_to_propose}`, |
| 741 | |
parent_unreachable: "502: the parent forge did not answer", |
| 742 | |
refused: "409: the parent forge refused, with the reason", |
| 743 | |
}, |
| 744 | |
}, |
| 745 | |
{ |
| 746 | |
method: "post", |
| 747 | |
path: `${REPO}/following/sever`, |
| 748 | |
summary: "Stop following the parent forge; answers { severedAt }", |
| 749 | |
auth: true, |
| 750 | |
refusals: { not_following: `404: ${FAMILY_WORDS.not_following}` }, |
| 751 | |
}, |
| 752 | |
{ |
| 753 | |
method: "post", |
| 754 | |
path: `${REPO}/following/take`, |
| 755 | |
summary: |
| 756 | |
"Take the parent's updates: a fork merges (the same answer as sync); the repository carrying this forge's code takes as a pull", |
| 757 | |
auth: true, |
| 758 | |
refusals: { |
| 759 | |
not_a_fork: "404: this repository follows nothing", |
| 760 | |
not_following: `404: ${FAMILY_WORDS.not_following}`, |
| 761 | |
conflict: "409: both sides changed these files; the crew was told; { files, converge, issue }", |
| 762 | |
moved: "409: something moved while this was read; try again", |
| 763 | |
}, |
| 764 | |
}, |
| 765 | |
{ |
| 766 | |
method: "patch", |
| 767 | |
path: "/api/v1/forge/following", |
| 768 | |
summary: "Choose how this forge follows the forge its code came from (its code repository's admin); answers { mode }", |
| 769 | |
auth: true, |
| 770 | |
body: { mode: "string" }, |
| 771 | |
refusals: { |
| 772 | |
not_following: `404: ${FAMILY_WORDS.not_following}`, |
| 773 | |
forbidden: "403: only this forge's owner can change how it follows", |
| 774 | |
invalid_mode: "422: mode is not one the forge knows", |
| 775 | |
severed: "409: this forge is severed and follows nothing", |
| 776 | |
}, |
| 777 | |
}, |
| 778 | |
{ |
| 779 | |
method: "put", |
| 780 | |
path: `${REPO}/upstream`, |
| 781 | |
summary: "Follow an outside upstream, or stop (admin scope, repository admin); answers { following }", |
| 782 | |
auth: true, |
| 783 | |
body: { url: "string", branch: "string", enabled: "boolean" }, |
| 784 | |
refusals: { |
| 785 | |
bad_enabled: "422: enabled is true or false", |
| 786 | |
invalid_url: "422: not an https or http git address", |
| 787 | |
invalid_branch: "422: not a branch name git accepts", |
| 788 | |
not_found: "404: no such repository, or it is not readable by the caller", |
| 789 | |
}, |
| 790 | |
}, |
| 791 | |
{ |
| 792 | |
method: "get", |
| 793 | |
path: `${REPO}/upstream`, |
| 794 | |
summary: "The outside upstream this repository follows, for an admin: { following } (null when it follows none)", |
| 795 | |
auth: true, |
| 796 | |
}, |
| 797 | |
{ |
| 798 | |
method: "post", |
| 799 | |
path: `${REPO}/upstream/check`, |
| 800 | |
summary: "Check the followed upstream now (admin scope); answers the check's result", |
| 801 | |
auth: true, |
| 802 | |
refusals: { busy: "409: a check is already running", invalid_url: "422: not an https or http git address" }, |
| 803 | |
}, |
| 804 | |
{ |
| 805 | |
method: "post", |
| 806 | |
path: `${REPO}/sync`, |
| 807 | |
summary: "Sync a mirror with its origin, or let a fork take its parent's updates (a fork needs a writer)", |
| 808 | |
auth: true, |
| 809 | |
refusals: { |
| 810 | |
not_a_fork: "404: this repository follows nothing", |
| 811 | |
conflict: "409: both sides changed these files; the crew was told", |
| 812 | |
moved: "409: something moved while this was read; try again", |
| 813 | |
}, |
| 814 | |
}, |
| 815 | |
{ |
| 816 | |
method: "post", |
| 817 | |
path: `${REPO}/make-it-yours`, |
| 818 | |
summary: |
| 819 | |
"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`", |
| 820 | |
auth: true, |
| 821 | |
body: { dryRun: "boolean", name: "string", visibility: "string" }, |
| 822 | |
refusals: { |
| 823 | |
invalid: "422: visibility is public or private", |
| 824 | |
own: "409: this app is already yours", |
| 825 | |
unverified: "403: prove your email first", |
| 826 | |
limit: "429: at most so many copies a day; { resetsAt }", |
| 827 | |
repository_limit: "403: at the repository limit", |
| 828 | |
storage_limit: "403: at the storage limit", |
| 829 | |
not_member: "403: not a member", |
| 830 | |
}, |
| 831 | |
}, |
| 832 | |
{ |
| 833 | |
method: "post", |
| 834 | |
path: `${REPO}/origin/reconnect`, |
| 835 | |
summary: "Reconnect a frozen mirror through one of your origins (admin scope, repository admin); answers the repository", |
| 836 | |
auth: true, |
| 837 | |
body: { originId: "string" }, |
| 838 | |
}, |
| 839 | |
{ |
| 840 | |
method: "post", |
| 841 | |
path: "/api/v1/user/origins/{id}/mirror", |
| 842 | |
summary: "Mirror a repository from one of your origins as a job; answers 202 with { job } to poll or stream", |
| 843 | |
auth: true, |
| 844 | |
body: { fullName: "string", name: "string" }, |
| 845 | |
status: 202, |
| 846 | |
refusals: { not_found: "404: no such origin of yours, or no such repository there" }, |
| 847 | |
}, |
| 848 | |
{ |
| 849 | |
method: "post", |
| 850 | |
path: "/api/v1/user/origins/{id}/fork", |
| 851 | |
summary: "Fork a repository at its origin, then mirror the fork as a job; answers 202 with { remote, existed, job, mirroredAs }", |
| 852 | |
auth: true, |
| 853 | |
body: { fullName: "string" }, |
| 854 | |
status: 202, |
| 855 | |
refusals: { |
| 856 | |
not_found: "404: no such origin of yours", |
| 857 | |
origin_unreachable: "422: the origin could not be reached", |
| 858 | |
fork_timeout: "504: the origin took the fork and it has not appeared yet; try again shortly", |
| 859 | |
mirror_failed: "502: a local copy of the fork could not be made", |
| 860 | |
}, |
| 861 | |
}, |
| 862 | |
{ |
| 863 | |
method: "get", |
| 864 | |
path: "/api/v1/user/origins/{id}/mirror/{job}", |
| 865 | |
summary: "Read a mirror job's stage: { job }; stages run creating, fetching, labels, issues, pulls, comments, crew, then done or failed", |
| 866 | |
auth: true, |
| 867 | |
refusals: { not_found: "404: no such origin or job of yours" }, |
| 868 | |
}, |
| 869 | |
{ |
| 870 | |
method: "get", |
| 871 | |
path: "/api/v1/user/origins/{id}/mirror/{job}/stream", |
| 872 | |
summary: "Watch a mirror job live; answers text/event-stream, one event per stage (mirror.creating, mirror.fetching, mirror.labels, mirror.issues, mirror.pulls, mirror.comments, mirror.crew, mirror.done, mirror.failed) carrying the job, and ends after done or failed", |
| 873 | |
auth: true, |
| 874 | |
refusals: { not_found: "404: no such origin or job of yours" }, |
| 875 | |
}, |
| 876 | |
{ |
| 877 | |
method: "post", |
| 878 | |
path: `${BROWSE}/contribute`, |
| 879 | |
summary: |
| 880 | |
"Contribute to a repository you only browse (admin scope): fork it if you cannot push, branch, commit files and optionally open a pull upstream; answers 201 with the contribution", |
| 881 | |
auth: true, |
| 882 | |
status: 201, |
| 883 | |
body: { branch: "string", base: "string", message: "string", files: "array", openPull: "object" }, |
| 884 | |
refusals: { |
| 885 | |
not_found: "404: no such origin of yours, or no such repository there", |
| 886 | |
unverified: "403: prove your email first", |
| 887 | |
origin_unreachable: "422: the origin could not be reached", |
| 888 | |
invalid_branch: "422: that branch is not one this forge can use", |
| 889 | |
unknown_base: "422: that base branch is not one this forge can use", |
| 890 | |
fork_timeout: "504: the origin took the fork and it has not appeared yet; try again shortly", |
| 891 | |
mirror_failed: "502: a local copy of the repository could not be made", |
| 892 | |
pull_failed: "422: the commit landed but the pull did not open; { reason, commit }", |
| 893 | |
}, |
| 894 | |
}, |
| 895 | |
{ method: "get", path: "/api/v1/user/origin-apps", summary: "List your registered OAuth applications, one per origin host", auth: true }, |
| 896 | |
{ |
| 897 | |
method: "post", |
| 898 | |
path: "/api/v1/user/origin-apps", |
| 899 | |
summary: "Register an OAuth application for an origin host (a forgejo or gitea needs its own; github may fall back to the forge's); answers 201 with the application", |
| 900 | |
auth: true, |
| 901 | |
body: { host: "string", kind: "string", clientId: "string", clientSecret: "string" }, |
| 902 | |
status: 201, |
| 903 | |
refusals: { |
| 904 | |
unknown_host: "422: that does not look like a forge host", |
| 905 | |
private_host: "422: that address is inside a network, not on the internet", |
| 906 | |
unreachable: "422: that host could not be reached", |
| 907 | |
}, |
| 908 | |
}, |
| 909 | |
{ |
| 910 | |
method: "delete", |
| 911 | |
path: "/api/v1/user/origin-apps/{id}", |
| 912 | |
summary: "Remove a registered OAuth application of yours; answers 204", |
| 913 | |
auth: true, |
| 914 | |
status: 204, |
| 915 | |
refusals: { not_found: "404: no such application of yours" }, |
| 916 | |
}, |
| 917 | |
{ method: "get", path: `${REPO}/forks`, summary: "List the forks of a repository you can read", auth: false }, |
| 918 | |
{ method: "get", path: `${REPO}/tags`, summary: "List a repository's tags: [{ name, oid, commit }]", auth: false }, |
| 919 | |
{ |
| 920 | |
method: "get", |
| 921 | |
path: "/api/v1/family", |
| 922 | |
summary: "The forge's family: { parent, here, children, more }; the parent for anyone, the children for a site admin", |
| 923 | |
auth: false, |
| 924 | |
}, |
| 925 | |
{ method: "get", path: `${REPO}/app/production/open`, summary: "Open the running app", auth: false }, |
| 926 | |
{ |
| 927 | |
method: "get", |
| 928 | |
path: `${REPO}/app/{env}/open`, |
| 929 | |
summary: |
| 930 | |
"The URL to open an environment at, as { url }; for a private app it carries a grant for the caller alone and is not cached", |
| 931 | |
auth: false, |
| 932 | |
refusals: { |
| 933 | |
not_found: "404: no such repository or app, or not the caller's to see", |
| 934 | |
no_such_env: "422: `env` is not an environment name", |
| 935 | |
no_runtime: "422: this forge runs no apps", |
| 936 | |
unauthorized: "401: a private app and no signed-in account", |
| 937 | |
}, |
| 938 | |
}, |
| 939 | |
{ method: "get", path: `${REPO}/app`, summary: "Read the repository's app: its settings, environments and state", auth: false }, |
| 940 | |
{ |
| 941 | |
method: "post", |
| 942 | |
path: `${REPO}/app`, |
| 943 | |
summary: "Turn the repository into an app (admin scope); `access` is public or private, `previewData` is copy or empty; answers 201 with the app", |
| 944 | |
auth: true, |
| 945 | |
body: { slug: "string", access: "string", previewData: "string" }, |
| 946 | |
status: 201, |
| 947 | |
refusals: { app_limit: "the owner is at the app limit" }, |
| 948 | |
}, |
| 949 | |
{ |
| 950 | |
method: "patch", |
| 951 | |
path: `${REPO}/app`, |
| 952 | |
summary: "Change the app's settings (admin scope); `access` is public or private, `previewData` is copy or empty", |
| 953 | |
auth: true, |
| 954 | |
body: { slug: "string", access: "string", previewData: "string" }, |
| 955 | |
}, |
| 956 | |
{ method: "delete", path: `${REPO}/app`, summary: "Turn the app off (admin scope); answers {}", auth: true }, |
| 957 | |
{ method: "get", path: `${REPO}/app/members`, summary: "Read the people a private app names; answers the members", auth: true }, |
| 958 | |
{ |
| 959 | |
method: "put", |
| 960 | |
path: `${REPO}/app/members`, |
| 961 | |
summary: "Set the people a private app names (admin scope, a person's, never an agent's); a plan unless dryRun is false", |
| 962 | |
auth: true, |
| 963 | |
body: { members: "array", dryRun: "boolean" }, |
| 964 | |
refusals: { invalid: "`members` is not a list of usernames" }, |
| 965 | |
}, |
| 966 | |
{ method: "get", path: `${REPO}/app/domains`, summary: "List the app's own domains (admin scope); answers { items, next_cursor }", auth: true }, |
| 967 | |
{ |
| 968 | |
method: "post", |
| 969 | |
path: `${REPO}/app/domains`, |
| 970 | |
summary: "Add a domain of your own to the app (admin scope); answers 201 with the domain", |
| 971 | |
auth: true, |
| 972 | |
body: { host: "string" }, |
| 973 | |
status: 201, |
| 974 | |
}, |
| 975 | |
{ method: "delete", path: `${REPO}/app/domains/{host}`, summary: "Remove a domain from the app (admin scope); answers {}", auth: true }, |
| 976 | |
{ method: "post", path: `${REPO}/app/domains/{host}/check`, summary: "Check that a domain points at the forge (admin scope); answers the domain", auth: true }, |
| 977 | |
{ method: "get", path: `${REPO}/app/env`, summary: "List the names of the app's environment variables (admin scope); a value is written and never read back; answers { items, next_cursor }", auth: true }, |
| 978 | |
{ |
| 979 | |
method: "put", |
| 980 | |
path: `${REPO}/app/env/{var}`, |
| 981 | |
summary: "Set an environment variable (admin scope); the value is written and never read back; scope is all (default), production or preview", |
| 982 | |
auth: true, |
| 983 | |
body: { value: "string", scope: "string" }, |
| 984 | |
}, |
| 985 | |
{ |
| 986 | |
method: "delete", |
| 987 | |
path: `${REPO}/app/env/{var}`, |
| 988 | |
summary: "Unset an environment variable (admin scope); scope is all (default), production or preview; answers {}", |
| 989 | |
auth: true, |
| 990 | |
query: { scope: "string" }, |
| 991 | |
}, |
| 992 | |
{ |
| 993 | |
method: "post", |
| 994 | |
path: `${REPO}/app/{env}/redeploy`, |
| 995 | |
summary: "Redeploy an environment (rw scope); answers 202 with { run }", |
| 996 | |
auth: true, |
| 997 | |
status: 202, |
| 998 | |
}, |
| 999 | |
{ method: "post", path: `${REPO}/app/{env}/stop`, summary: "Stop an environment (rw scope); answers {}", auth: true }, |
| 1000 | |
{ method: "get", path: `${REPO}/map`, summary: "Read the plan: issues, their dependency edges and pillars", auth: false }, |
| 1001 | |
{ |
| 1002 | |
method: "post", |
| 1003 | |
path: `${REPO}/plan`, |
| 1004 | |
summary: "Open a plan of issues and their dependencies, all or nothing", |
| 1005 | |
auth: true, |
| 1006 | |
body: { issues: "array" }, |
| 1007 | |
}, |
| 1008 | |
{ |
| 1009 | |
method: "post", |
| 1010 | |
path: "/api/v1/agents", |
| 1011 | |
summary: "Make an agent on the crew (an admin token, or a person's session); answers 201 with the agent", |
| 1012 | |
auth: true, |
| 1013 | |
status: 201, |
| 1014 | |
body: { username: "string", displayName: "string", model: "string", brief: "string", budgetUsd: "integer" }, |
| 1015 | |
refusals: { forbidden: "a token below admin scope", unauthorized: "no account" }, |
| 1016 | |
}, |
| 1017 | |
{ method: "get", path: "/api/v1/agents/{username}", summary: "Read one agent; the brief, model and budget show to those who may see them", auth: false, refusals: { not_found: "no such agent, or not yours to see" } }, |
| 1018 | |
{ |
| 1019 | |
method: "patch", |
| 1020 | |
path: "/api/v1/agents/{username}", |
| 1021 | |
summary: "Change an agent's model, brief or budget (an admin token, or a person's session); answers the agent", |
| 1022 | |
auth: true, |
| 1023 | |
body: { model: "string", brief: "string", budgetUsd: "integer" }, |
| 1024 | |
refusals: { forbidden: "a token below admin scope" }, |
| 1025 | |
}, |
| 1026 | |
{ method: "delete", path: "/api/v1/agents/{username}", summary: "Remove an agent (an admin token, or a person's session); answers {}", auth: true, refusals: { forbidden: "a token below admin scope", not_found: "no such agent" } }, |
| 1027 | |
{ |
| 1028 | |
method: "post", |
| 1029 | |
path: "/api/v1/orgs", |
| 1030 | |
summary: "Make an organisation (rw scope); answers 201 with { id, username, kind: \"org\" }", |
| 1031 | |
auth: true, |
| 1032 | |
status: 201, |
| 1033 | |
body: { name: "string" }, |
| 1034 | |
}, |
| 1035 | |
{ |
| 1036 | |
method: "post", |
| 1037 | |
path: "/api/v1/orgs/{org}/teams", |
| 1038 | |
summary: "Make a team in an organisation (admin scope); answers 201 with the team", |
| 1039 | |
auth: true, |
| 1040 | |
status: 201, |
| 1041 | |
body: { name: "string", permission: "string" }, |
| 1042 | |
refusals: { not_found: "no such organisation, or not yours to see" }, |
| 1043 | |
}, |
| 1044 | |
{ |
| 1045 | |
method: "delete", |
| 1046 | |
path: "/api/v1/orgs/{org}/teams/{team}", |
| 1047 | |
summary: "Remove a team (admin scope); answers 204", |
| 1048 | |
auth: true, |
| 1049 | |
status: 204, |
| 1050 | |
refusals: { not_found: "no such team" }, |
| 1051 | |
}, |
| 1052 | |
{ |
| 1053 | |
method: "post", |
| 1054 | |
path: "/api/v1/orgs/{org}/teams/{team}/members", |
| 1055 | |
summary: "Add a user to a team (admin scope); answers 201 with { added: true }, or 200 when they were already in", |
| 1056 | |
auth: true, |
| 1057 | |
status: 201, |
| 1058 | |
body: { user: "string" }, |
| 1059 | |
}, |
| 1060 | |
{ |
| 1061 | |
method: "delete", |
| 1062 | |
path: "/api/v1/orgs/{org}/teams/{team}/members/{username}", |
| 1063 | |
summary: "Take a user out of a team (admin scope); answers 204", |
| 1064 | |
auth: true, |
| 1065 | |
status: 204, |
| 1066 | |
refusals: { |
| 1067 | |
not_found: "no such team, or the user is not in it", |
| 1068 | |
last_owner: "409, an organisation keeps at least one owner; make someone else an owner first", |
| 1069 | |
}, |
| 1070 | |
}, |
| 1071 | |
{ method: "get", path: "/api/v1/contribute", summary: "What an agent that arrives should know: { forge, bottleneck, welcomeLabel, issues, propose, telemetry }; issues are the open welcome-labelled ones in public repositories", auth: false }, |
| 1072 | |
{ |
| 1073 | |
method: "post", |
| 1074 | |
path: `${REPO}/contribute`, |
| 1075 | |
summary: |
| 1076 | |
"Send files to a mirror's upstream as a branch, and optionally a pull (rw scope); answers 201 with the contribution; a repository that is not a mirror answers not_found", |
| 1077 | |
auth: true, |
| 1078 | |
status: 201, |
| 1079 | |
body: { branch: "string", base: "string", message: "string", files: "array", openPull: "object" }, |
| 1080 | |
}, |
| 1081 | |
{ |
| 1082 | |
method: "post", |
| 1083 | |
path: `${REPO}/edits`, |
| 1084 | |
summary: |
| 1085 | |
"Commit files without git (rw scope): files is [{ path, content }], content null deletes; answers 201 with { commit, branch, pull }", |
| 1086 | |
auth: true, |
| 1087 | |
status: 201, |
| 1088 | |
body: { branch: "string", base: "string", message: "string", newBranch: "string", files: "array", openPull: "object" }, |
| 1089 | |
refusals: { no_files: "files holds nothing to write" }, |
| 1090 | |
}, |
| 1091 | |
{ |
| 1092 | |
method: "post", |
| 1093 | |
path: `${REPO}/branches`, |
| 1094 | |
summary: |
| 1095 | |
"Create a branch (rw scope), from `from` or the default branch; answers 201 with the branch", |
| 1096 | |
auth: true, |
| 1097 | |
status: 201, |
| 1098 | |
body: { name: "string", from: "string" }, |
| 1099 | |
refusals: { |
| 1100 | |
invalid_name: "name is not a branch name", |
| 1101 | |
unknown_start: "from names nothing in the repository", |
| 1102 | |
exists: "the branch is already there (409)", |
| 1103 | |
origin_refused: "a mirror's origin refused the branch", |
| 1104 | |
}, |
| 1105 | |
}, |
| 1106 | |
{ |
| 1107 | |
method: "delete", |
| 1108 | |
path: `${REPO}/branches/{branch}`, |
| 1109 | |
summary: |
| 1110 | |
"Delete a branch (rw scope); the name may hold slashes; answers 204 with no body", |
| 1111 | |
auth: true, |
| 1112 | |
status: 204, |
| 1113 | |
refusals: { |
| 1114 | |
invalid_name: "not a branch name", |
| 1115 | |
default_branch: "the default branch cannot be deleted (409)", |
| 1116 | |
unknown_branch: "no such branch (404)", |
| 1117 | |
origin_refused: "a mirror's origin refused the deletion", |
| 1118 | |
}, |
| 1119 | |
}, |
| 1120 | |
{ |
| 1121 | |
method: "post", |
| 1122 | |
path: `${REPO}/contents`, |
| 1123 | |
summary: |
| 1124 | |
"Commit files to a branch without git (rw scope): files is [{ path, content }]; answers 201 with the commit", |
| 1125 | |
auth: true, |
| 1126 | |
status: 201, |
| 1127 | |
body: { branch: "string", base: "string", message: "string", files: "array" }, |
| 1128 | |
refusals: { |
| 1129 | |
invalid_branch: "branch or base is not a safe ref name", |
| 1130 | |
invalid_path: "a file path is not a safe relative path", |
| 1131 | |
path_conflict: "a path is a directory in the tree, or an ancestor of it is a file", |
| 1132 | |
no_files: "files holds nothing to write", |
| 1133 | |
protected_branch: "the default branch is protected and the caller is not a repo admin", |
| 1134 | |
file_too_large: "a file is over the per-file limit", |
| 1135 | |
commit_too_large: "the files together are over the per-commit limit", |
| 1136 | |
branch_moved: "the branch moved while committing (409)", |
| 1137 | |
land_refused: "the commit could not be landed on the branch", |
| 1138 | |
git: "git failed while committing", |
| 1139 | |
}, |
| 1140 | |
}, |
| 1141 | |
{ |
| 1142 | |
method: "post", |
| 1143 | |
path: "/api/v1/thoughts", |
| 1144 | |
summary: "Keep a thought (rw scope); answers 201 with { id }", |
| 1145 | |
auth: true, |
| 1146 | |
status: 201, |
| 1147 | |
body: { text: "string" }, |
| 1148 | |
refusals: { invalid: "text is not a string within the length limit" }, |
| 1149 | |
}, |
| 1150 | |
{ |
| 1151 | |
method: "get", |
| 1152 | |
path: "/api/v1/asked", |
| 1153 | |
summary: "What people asked, and the caller's thought stream; answers { asked, thoughts } (ro scope)", |
| 1154 | |
auth: true, |
| 1155 | |
query: { show: "string", order: "string", repo: "string" }, |
| 1156 | |
refusals: { invalid_show: "show is all, unshipped or flagged", invalid_order: "order is oldest or newest" }, |
| 1157 | |
}, |
| 1158 | |
{ |
| 1159 | |
method: "get", |
| 1160 | |
path: "/api/v1/returns", |
| 1161 | |
summary: "What the work returned over the last days, for the caller or one repository; never cached", |
| 1162 | |
auth: true, |
| 1163 | |
query: { repo: "string", days: "integer" }, |
| 1164 | |
refusals: { invalid_days: "days is not a whole number in range" }, |
| 1165 | |
}, |
| 1166 | |
{ method: "get", path: "/api/v1/here", summary: "The manifest of what you can do at `path` (default /), given who you are: { here, you, actions, mcp, docs }; never cached", auth: false, query: { path: "string" }, refusals: { not_found: "404: the path names no page you can read" } }, |
| 1167 | |
{ |
| 1168 | |
method: "get", |
| 1169 | |
path: `${REPO}/issues/{number}/dependencies`, |
| 1170 | |
summary: "What the issue waits for and what waits for it: { blockedBy, blocks }, each a list of { number, title, state, closeReason? }; issues the caller cannot see are left out", |
| 1171 | |
auth: false, |
| 1172 | |
}, |
| 1173 | |
{ |
| 1174 | |
method: "post", |
| 1175 | |
path: `${REPO}/issues/{number}/dependencies`, |
| 1176 | |
summary: "Make an issue wait for another (rw scope); answers 201 with { issue, blockedBy }", |
| 1177 | |
auth: true, |
| 1178 | |
status: 201, |
| 1179 | |
body: { blockedBy: "integer" }, |
| 1180 | |
refusals: { |
| 1181 | |
no_such_issue: "`blockedBy` names no issue in this repository (422)", |
| 1182 | |
self: `${SELF_WAIT} (422)`, |
| 1183 | |
cycle: "that would make a loop (422)", |
| 1184 | |
cross_repo: "dependencies stay inside one repository (422)", |
| 1185 | |
not_an_issue: "only issues can depend on issues (422)", |
| 1186 | |
}, |
| 1187 | |
}, |
| 1188 | |
{ |
| 1189 | |
method: "delete", |
| 1190 | |
path: `${REPO}/issues/{number}/dependencies/{blocker}`, |
| 1191 | |
summary: "Stop an issue waiting for another", |
| 1192 | |
auth: true, |
| 1193 | |
}, |
| 1194 | |
{ method: "get", path: "/api/v1/forges", summary: "List the child forges (a site admin's, ro scope); answers { items, next_cursor: null }", auth: true }, |
| 1195 | |
{ |
| 1196 | |
method: "post", |
| 1197 | |
path: "/api/v1/forges", |
| 1198 | |
summary: |
| 1199 | |
"Make a forge, as a link: `dryRun` is true unless it is false and then answers 200 with { dryRun: true, plan, firstAdmin } and makes nothing; made, it answers 201 (200 when it already exists) with { dryRun: false, name, url, claimUrl, ... }. A signed-in caller needs rw scope; a stranger may call it, same-origin, rate-limited, with no crew, model key or `for`", |
| 1200 | |
auth: true, |
| 1201 | |
status: 201, |
| 1202 | |
body: { |
| 1203 | |
name: "string", |
| 1204 | |
apps: "array", |
| 1205 | |
crew: "array", |
| 1206 | |
dryRun: "boolean", |
| 1207 | |
direction: "string", |
| 1208 | |
keepAccess: "boolean", |
| 1209 | |
size: "string", |
| 1210 | |
for: "string", |
| 1211 | |
fresh: "boolean", |
| 1212 | |
followMode: "string", |
| 1213 | |
budgetUsd: "number", |
| 1214 | |
modelKey: "string", |
| 1215 | |
modelEndpoint: "string", |
| 1216 | |
}, |
| 1217 | |
refusals: { |
| 1218 | |
invalid: "a field is missing or not valid", |
| 1219 | |
rate_limited: "a stranger has started enough forges today", |
| 1220 | |
}, |
| 1221 | |
}, |
| 1222 | |
{ method: "get", path: "/api/v1/forges/sizes", summary: "What small, medium and large mean; public, cacheable by ETag", auth: false }, |
| 1223 | |
{ |
| 1224 | |
method: "post", |
| 1225 | |
path: "/api/v1/forges/{name}/claim-link", |
| 1226 | |
summary: |
| 1227 | |
"Make a new claim link for a forge nobody has claimed (a site admin's, rw scope); answers 201 with { name, claimUrl }. The link is shown once, here, and not again: the old link dies, so keep this one", |
| 1228 | |
auth: true, |
| 1229 | |
status: 201, |
| 1230 | |
refusals: { not_found: "no child forge has this name", forbidden: "the caller is not a site admin", claimed: "the forge is already claimed" }, |
| 1231 | |
}, |
| 1232 | |
{ method: "get", path: "/api/v1/forge", summary: "A spawned forge's word about itself: { claimed, lastActivityAt }; lastActivityAt is null unless the parent's secret is sent", auth: false, refusals: { not_found: "404: this forge was not spawned" } }, |
| 1233 | |
{ |
| 1234 | |
method: "get", |
| 1235 | |
path: "/api/v1/fleet/graph", |
| 1236 | |
summary: "Spend and runs per repository by UTC hour (24 h) and day (14 d); `repo` (owner/name) repeats, else every repository the caller may write that spent lately", |
| 1237 | |
auth: true, |
| 1238 | |
query: { repo: "string" }, |
| 1239 | |
refusals: { invalid_selection: "`repo` is not a list of owner/name" }, |
| 1240 | |
}, |
| 1241 | |
{ |
| 1242 | |
method: "get", |
| 1243 | |
path: "/api/v1/fleet/ops", |
| 1244 | |
summary: "List the fleet operations, newest first; answers { items, next_cursor }", |
| 1245 | |
auth: true, |
| 1246 | |
query: { limit: "integer", cursor: "string" }, |
| 1247 | |
}, |
| 1248 | |
{ method: "get", path: "/api/v1/fleet/ops/{id}", summary: "Read one fleet operation and what it changed", auth: false }, |
| 1249 | |
{ |
| 1250 | |
method: "post", |
| 1251 | |
path: "/api/v1/fleet/ops", |
| 1252 | |
summary: |
| 1253 | |
"Plan or apply one action over the repositories a query selects; `dryRun` is true unless it is false: the plan needs ro scope and changes nothing (200, { dryRun: true, ... }), applying needs admin scope and answers 201 with { dryRun: false, ... }; each row is still checked against the actor's admin of its repository", |
| 1254 | |
auth: true, |
| 1255 | |
status: 201, |
| 1256 | |
body: { query: "string", action: "object", dryRun: "boolean" }, |
| 1257 | |
}, |
| 1258 | |
{ method: "post", path: "/api/v1/fleet/ops/{id}/undo", summary: "Undo a fleet operation (admin scope); answers 201 with the undoing operation", auth: true, status: 201 }, |
| 1259 | |
{ method: "get", path: "/api/v1/front", summary: "The front page a stranger is shown: { featured, apps, total }, each entry { repo, description, url, state, picture, pinned, score, signals }; a site admin also gets `picks`", auth: false }, |
| 1260 | |
{ |
| 1261 | |
method: "put", |
| 1262 | |
path: "/api/v1/front", |
| 1263 | |
summary: "Set the front page's picks (a site admin's, rw scope): `pinned` and `hidden` are arrays of owner/name, `weights` an object of forks, ships, use, fresh; answers the picks", |
| 1264 | |
auth: true, |
| 1265 | |
body: { pinned: "array", hidden: "array", weights: "object" }, |
| 1266 | |
refusals: { invalid: "the picks are malformed or name no repository (422)", forbidden: `${ADMIN_WORDS.frontPage} (403)` }, |
| 1267 | |
}, |
| 1268 | |
{ |
| 1269 | |
method: "get", |
| 1270 | |
path: "/api/v1/health", |
| 1271 | |
summary: "How the forge is, uncached; anyone may ask, a stranger is told little, the release probe and a site admin the whole of it", |
| 1272 | |
auth: false, |
| 1273 | |
}, |
| 1274 | |
{ |
| 1275 | |
method: "get", |
| 1276 | |
path: "/api/v1/passport/health", |
| 1277 | |
summary: |
| 1278 | |
"The forge's public health, for a caller holding a passport (`Authorization: Passport <token>`, audience `forge`, scope `health:read`) rather than a token; answers the same body as the anonymous health, uncached", |
| 1279 | |
auth: false, |
| 1280 | |
refusals: { unauthorized: "the passport is missing, malformed, expired, or not for this audience and scope (always 401)" }, |
| 1281 | |
}, |
| 1282 | |
{ method: "get", path: "/api/v1/map", summary: "The global map: every repository the caller can see, with its issues and dependency edges", auth: false }, |
| 1283 | |
{ |
| 1284 | |
method: "post", |
| 1285 | |
path: "/api/v1/heartbeat", |
| 1286 | |
summary: |
| 1287 | |
"A child forge's signed heartbeat (counts and version, never data); only a child holding the parent's secret may call it, signed in the signature header, and body at most 1 KiB; answers { ok: true }, and every refusal is the same 404 `not_found`", |
| 1288 | |
auth: false, |
| 1289 | |
refusals: { not_found: "the beat is not signed by a child of this forge" }, |
| 1290 | |
}, |
| 1291 | |
{ |
| 1292 | |
method: "post", |
| 1293 | |
path: "/api/v1/drills", |
| 1294 | |
summary: |
| 1295 | |
"Record a domain drill and its verdict, green or red (a site admin's, admin token only); answers 201 with the recorded drill", |
| 1296 | |
auth: true, |
| 1297 | |
status: 201, |
| 1298 | |
body: { domain: "string", seen: "object", backSeconds: "integer" }, |
| 1299 | |
refusals: { |
| 1300 | |
unknown_domain: "`domain` is not one of the forge's failure domains", |
| 1301 | |
invalid_seen: "`seen` needs health and board as text", |
| 1302 | |
invalid_back: "`backSeconds` is not a whole number of seconds, 0 to 31536000", |
| 1303 | |
too_large: "the body is too large", |
| 1304 | |
}, |
| 1305 | |
}, |
| 1306 | |
{ |
| 1307 | |
method: "post", |
| 1308 | |
path: "/api/v1/migrate", |
| 1309 | |
summary: |
| 1310 | |
"Migrate a repository here: source is { url } (needs rw scope) or { originId, fullName } (needs admin scope); after is leave, archive or mirror; branch defaults to the one with the newest commits; crew is { agent, autonomous }; answers 202 with { repo, job }, the clone job watched at /api/v1/migrate/{job}", |
| 1311 | |
auth: true, |
| 1312 | |
status: 202, |
| 1313 | |
body: { source: "object", owner: "string", name: "string", visibility: "string", branch: "string", after: "string", crew: "object" }, |
| 1314 | |
refusals: { |
| 1315 | |
unauthorized: "401: no signed-in account", |
| 1316 | |
invalid_source: "422: source is neither { url } nor { originId, fullName }", |
| 1317 | |
invalid_after: "400: `after` is not leave, archive or mirror", |
| 1318 | |
unverified: "403: the account has not verified its email", |
| 1319 | |
repository_limit: "403: the owner is at the repository limit", |
| 1320 | |
storage_limit: "403: the owner is at the storage limit", |
| 1321 | |
invalid_url: "422: the URL is not one this forge will clone from", |
| 1322 | |
private_host: "422: the URL is inside the network", |
| 1323 | |
no_such_origin: "422: the origin is not the caller's, or has no such repository", |
| 1324 | |
unreachable: "422: the source could not be reached", |
| 1325 | |
invalid_branch: "422: the branch is not in the source", |
| 1326 | |
no_such_agent: "422: `crew.agent` names no one on the crew", |
| 1327 | |
}, |
| 1328 | |
}, |
| 1329 | |
{ |
| 1330 | |
method: "get", |
| 1331 | |
path: "/api/v1/migrate/{job}", |
| 1332 | |
summary: "Read a migration job's stage as { job }; only the account that started it can", |
| 1333 | |
auth: true, |
| 1334 | |
refusals: { unauthorized: "401: no signed-in account", not_found: "404: no such job, or not the caller's" }, |
| 1335 | |
}, |
| 1336 | |
{ |
| 1337 | |
method: "get", |
| 1338 | |
path: "/api/v1/migrate/{job}/stream", |
| 1339 | |
summary: |
| 1340 | |
"Watch a migration job as an event stream (text/event-stream, not JSON): one `migrate.<stage>` event per stage with the job as data, closing after done or failed", |
| 1341 | |
auth: true, |
| 1342 | |
refusals: { unauthorized: "401: no signed-in account", not_found: "404: no such job, or not the caller's" }, |
| 1343 | |
}, |
| 1344 | |
{ |
| 1345 | |
method: "get", |
| 1346 | |
path: `${REPO}/issues/{number}/teams`, |
| 1347 | |
summary: "Read the crew teams named on an issue; answers { teams }, a list of team slugs", |
| 1348 | |
auth: false, |
| 1349 | |
}, |
| 1350 | |
{ |
| 1351 | |
method: "put", |
| 1352 | |
path: `${REPO}/issues/{number}/teams`, |
| 1353 | |
summary: "Replace the crew teams named on an issue (at most two); the pull that closes it is reviewed by each; answers { teams }, the slugs now named. A body without a list of slug strings is a 400", |
| 1354 | |
auth: true, |
| 1355 | |
body: { teams: "array" }, |
| 1356 | |
refusals: { |
| 1357 | |
too_many: "more teams than an issue may name", |
| 1358 | |
no_such_team: "a slug names no team", |
| 1359 | |
mirrored: "the repository mirrors an origin; do it there", |
| 1360 | |
forbidden: "the caller cannot write to the repository", |
| 1361 | |
}, |
| 1362 | |
}, |
| 1363 | |
{ |
| 1364 | |
method: "put", |
| 1365 | |
path: `${REPO}/issues/{number}/labels`, |
| 1366 | |
summary: "Replace the label set of an issue (names or ids; the set is all or nothing); answers the labels it now carries as a JSON array", |
| 1367 | |
auth: true, |
| 1368 | |
body: { labels: "array" }, |
| 1369 | |
refusals: LABEL_REFUSALS, |
| 1370 | |
}, |
| 1371 | |
{ |
| 1372 | |
method: "put", |
| 1373 | |
path: `${REPO}/pulls/{number}/labels`, |
| 1374 | |
summary: "Replace the label set of a pull (names or ids; the set is all or nothing); answers the labels it now carries as a JSON array", |
| 1375 | |
auth: true, |
| 1376 | |
body: { labels: "array" }, |
| 1377 | |
refusals: LABEL_REFUSALS, |
| 1378 | |
}, |
| 1379 | |
{ |
| 1380 | |
method: "put", |
| 1381 | |
path: `${REPO}/milestones/{number}/watch`, |
| 1382 | |
summary: "Watch a milestone: the caller's own watch, so a write token and nothing more (a person's; an agent is refused); answers { watching }", |
| 1383 | |
auth: true, |
| 1384 | |
refusals: { |
| 1385 | |
unauthorized: "401: no signed-in account", |
| 1386 | |
forbidden: "403: only a person watches a milestone", |
| 1387 | |
not_found: "404: no such repository, or it is not readable, or the number is no milestone", |
| 1388 | |
}, |
| 1389 | |
}, |
| 1390 | |
{ |
| 1391 | |
method: "delete", |
| 1392 | |
path: `${REPO}/milestones/{number}/watch`, |
| 1393 | |
summary: "Stop watching a milestone: the caller's own watch, so a write token and nothing more (a person's; an agent is refused); answers { watching }", |
| 1394 | |
auth: true, |
| 1395 | |
refusals: { |
| 1396 | |
unauthorized: "401: no signed-in account", |
| 1397 | |
forbidden: "403: only a person watches a milestone", |
| 1398 | |
not_found: "404: no such repository, or it is not readable, or the number is no milestone", |
| 1399 | |
}, |
| 1400 | |
}, |
| 1401 | |
{ |
| 1402 | |
method: "post", |
| 1403 | |
path: `${REPO}/issues/{number}/private`, |
| 1404 | |
summary: "Make, or find, the private repository where a sealed report is fixed by hand; answers 201 { repo, clone }: `owner/name` and its clone URL", |
| 1405 | |
auth: true, |
| 1406 | |
status: 201, |
| 1407 | |
refusals: { |
| 1408 | |
not_found: "no such issue, or sealed reports are not the caller's to see", |
| 1409 | |
not_sealed: "the issue is not sealed, so it has nothing to hide", |
| 1410 | |
unverified: "the account has not verified its email", |
| 1411 | |
name_taken: "the owner already has a repository of the private name", |
| 1412 | |
limit: "the owner is at the daily fork limit", |
| 1413 | |
fork_failed: "the private repository could not be made", |
| 1414 | |
}, |
| 1415 | |
}, |
| 1416 | |
{ |
| 1417 | |
method: "get", |
| 1418 | |
path: `${REPO}/flow`, |
| 1419 | |
summary: "Read how work moves through a repository: { open, live }, where open lists each open issue (and merged ones production has not yet served) as { number, title, stage, since, stalled, unblock, pull } and live lists what went live in the last 24 hours, newest first, the same with liveAt; sealed rows only for those who may see them", |
| 1420 | |
auth: false, |
| 1421 | |
refusals: { not_found: "no such repository, or it is not readable by the caller" }, |
| 1422 | |
}, |
| 1423 | |
{ |
| 1424 | |
method: "get", |
| 1425 | |
path: "/api/v1/crew/scorecard", |
| 1426 | |
summary: "Read the crew's scorecard (a forge admin's, any token scope): { agents: [{ username, all, recent, splits }] }, where all and recent are { opened, landed, reviewRounds, redCheckRate, reverts, meanLandMs } or null, and each split adds model, prompt and its own recent", |
| 1427 | |
auth: true, |
| 1428 | |
refusals: { unauthorized: "no valid credentials were sent", forbidden: "the caller is not a forge admin" }, |
| 1429 | |
}, |
| 1430 | |
{ |
| 1431 | |
method: "post", |
| 1432 | |
path: "/api/v1/users", |
| 1433 | |
summary: |
| 1434 | |
"Sign up: answers 201 with the new account { id, username, displayName, isAdmin, commitEmail }, never the admin; `email` is optional; `invite` is an invite link's token, `turnstile_token` the human check when the forge asks for one; refused from another origin with 403 forbidden", |
| 1435 | |
auth: false, |
| 1436 | |
status: 201, |
| 1437 | |
body: { username: "string", email: "string", password: "string", invite: "string", turnstile_token: "string" }, |
| 1438 | |
refusals: { |
| 1439 | |
rate_limited: "429, too many sign-ups; retry-after says when", |
| 1440 | |
too_large: "413, the body is over the limit for a stranger's", |
| 1441 | |
signup_closed: "403, this forge makes no new accounts (or the invite is not one)", |
| 1442 | |
forbidden: "403, the name is not one this edge allows", |
| 1443 | |
passkey_required: "422, this forge signs up with a passkey on the web page", |
| 1444 | |
human_check: "422, the human check failed", |
| 1445 | |
invalid_username: "422, not a name an account may have", |
| 1446 | |
invalid_email: "422, not an email address", |
| 1447 | |
weak_password: "422, the password is too short", |
| 1448 | |
password_too_long: "422, the password is over the cap", |
| 1449 | |
username_taken: "422, another account has the name", |
| 1450 | |
username_reserved: "422, the name is reserved", |
| 1451 | |
email_taken: "422, another account has the address", |
| 1452 | |
}, |
| 1453 | |
}, |
| 1454 | |
{ |
| 1455 | |
method: "post", |
| 1456 | |
path: "/api/v1/sessions", |
| 1457 | |
summary: |
| 1458 | |
"Sign in with a username and password: answers 201 with the account { id, username, displayName, isAdmin, commitEmail } and sets the HttpOnly forge_session cookie; refused from another origin with 403 forbidden", |
| 1459 | |
auth: false, |
| 1460 | |
status: 201, |
| 1461 | |
body: { username: "string", password: "string" }, |
| 1462 | |
refusals: { |
| 1463 | |
unauthorized: "401, the username or password is wrong", |
| 1464 | |
too_many_attempts: "429, past the sign-in windows; retry-after says when", |
| 1465 | |
too_large: "413, the body is over the limit for a stranger's", |
| 1466 | |
}, |
| 1467 | |
}, |
| 1468 | |
{ |
| 1469 | |
method: "delete", |
| 1470 | |
path: "/api/v1/sessions", |
| 1471 | |
summary: "Sign out the session in the forge_session cookie: answers 200 with {} and clears the cookie; refused from another origin with 403 forbidden", |
| 1472 | |
auth: false, |
| 1473 | |
}, |
| 1474 | |
{ |
| 1475 | |
method: "post", |
| 1476 | |
path: "/api/v1/first-admin", |
| 1477 | |
summary: |
| 1478 | |
"Make the forge's first admin with the operator's one-time code, no session or password: answers 201 with { user, scope, id, token, expiresAt, expiresIn }, the token shown once; scope is ro, rw or admin; expiresIn is whole seconds from 60 to a month (default a day); refused from another origin with 403 forbidden", |
| 1479 | |
auth: false, |
| 1480 | |
status: 201, |
| 1481 | |
body: { code: "string", username: "string", email: "string", scope: "string", name: "string", expiresIn: "integer" }, |
| 1482 | |
refusals: { |
| 1483 | |
too_large: "413, the body is over the limit for a stranger's", |
| 1484 | |
invalid_scope: "422, scope is not ro, rw or admin", |
| 1485 | |
invalid_expiry: "422, expiresIn is not whole seconds in range", |
| 1486 | |
rate_limited: "429, too many attempts from here; retry-after says when", |
| 1487 | |
refused: "403, the code does not work or this forge already has an admin", |
| 1488 | |
invalid_username: "422, not a name an account may have", |
| 1489 | |
invalid_email: "422, not an email address", |
| 1490 | |
username_taken: "422, another account has the name", |
| 1491 | |
email_taken: "422, another account has the address", |
| 1492 | |
username_reserved: "422, the name is reserved", |
| 1493 | |
unfinished: "422, the account could not be finished; send again", |
| 1494 | |
}, |
| 1495 | |
}, |
| 1496 | |
{ |
| 1497 | |
method: "post", |
| 1498 | |
path: "/api/v1/first-admin/recover", |
| 1499 | |
summary: |
| 1500 | |
"Get a stranded admin a new expiring token with the operator's recovery code; no account is made: answers 201 with { user, scope, id, token, expiresAt, expiresIn }; scope and expiresIn as for first-admin; refused from another origin with 403 forbidden", |
| 1501 | |
auth: false, |
| 1502 | |
status: 201, |
| 1503 | |
body: { code: "string", scope: "string", name: "string", expiresIn: "integer" }, |
| 1504 | |
refusals: { |
| 1505 | |
too_large: "413, the body is over the limit for a stranger's", |
| 1506 | |
invalid_scope: "422, scope is not ro, rw or admin", |
| 1507 | |
invalid_expiry: "422, expiresIn is not whole seconds in range", |
| 1508 | |
rate_limited: "429, too many attempts from here; retry-after says when", |
| 1509 | |
refused: "403, the code does not work", |
| 1510 | |
}, |
| 1511 | |
}, |
| 1512 | |
{ |
| 1513 | |
method: "get", |
| 1514 | |
path: "/api/v1/openapi.json", |
| 1515 | |
summary: "The address an agent tries first: answers 308 to /contract/openapi.json, this document", |
| 1516 | |
auth: false, |
| 1517 | |
status: 308, |
| 1518 | |
}, |
| 1519 | |
{ |
| 1520 | |
method: "get", |
| 1521 | |
path: "/metrics", |
| 1522 | |
summary: |
| 1523 | |
"The forge's own figures as Prometheus text (text/plain; version=0.0.4), for a scraper; a site admin only, with a token of any scope; see docs/METRICS.md", |
| 1524 | |
auth: true, |
| 1525 | |
status: 200, |
| 1526 | |
refusals: { not_found: "404, the caller is not a site admin" }, |
| 1527 | |
}, |
| 1528 | |
{ |
| 1529 | |
method: "post", |
| 1530 | |
path: "/api/v1/user/password", |
| 1531 | |
summary: "Change the caller's password: current is verified, next replaces it; answers 200 with {}. A person's password or an admin-scope token only", |
| 1532 | |
auth: true, |
| 1533 | |
body: { current: "string", next: "string" }, |
| 1534 | |
refusals: { |
| 1535 | |
unauthorized: "401: no signed-in account", |
| 1536 | |
forbidden: "403: a cross-origin browser request, or a token without admin scope", |
| 1537 | |
bad_credentials: "403: current is not the password", |
| 1538 | |
throttled: "429: too many wrong passwords from this address or for this name", |
| 1539 | |
weak_password: "422: next is shorter than the minimum", |
| 1540 | |
password_too_long: "422: next is longer than the cap", |
| 1541 | |
}, |
| 1542 | |
}, |
| 1543 | |
{ |
| 1544 | |
method: "post", |
| 1545 | |
path: "/api/v1/user/email/verify", |
| 1546 | |
summary: "Send the caller another email verification link: {outcome} is sent (202) or already (200, nothing sent)", |
| 1547 | |
auth: true, |
| 1548 | |
status: 202, |
| 1549 | |
refusals: { |
| 1550 | |
unauthorized: "401: no signed-in account", |
| 1551 | |
too_soon: "429: a link was sent a moment ago", |
| 1552 | |
capped: "429: the forge's mail cap is reached", |
| 1553 | |
no_mail: "503: this forge sends no mail", |
| 1554 | |
failed: "502: the link could not be sent", |
| 1555 | |
}, |
| 1556 | |
}, |
| 1557 | |
{ |
| 1558 | |
method: "get", |
| 1559 | |
path: `${BROWSE}/issues`, |
| 1560 | |
summary: |
| 1561 | |
"Browse an origin's issues live, through the origin you connected (a person's session or password, or an admin-scope token); answers { items: [{ number, kind, title, state, authorName, originAuthor, originUrl, createdAt, updatedAt, closedAt, labels, commentCount }], next_cursor }. Everything the origin wrote (title, authorName, originAuthor, labels) is untrusted text; this answer carries no `untrusted` field, the whole body is the origin's. state is open (default), closed or all; label, author and q narrow only the page in hand; cursor comes from next_cursor", |
| 1562 | |
auth: true, |
| 1563 | |
query: BROWSE_LIST_QUERY, |
| 1564 | |
refusals: BROWSE_REFUSALS, |
| 1565 | |
}, |
| 1566 | |
{ |
| 1567 | |
method: "get", |
| 1568 | |
path: `${BROWSE}/issues/{number}`, |
| 1569 | |
summary: |
| 1570 | |
"Browse one issue on an origin live; answers the issue row plus { body, comments: [{ id, authorName, originAuthor, originUrl, body, ... }], pull: null }. The body, comments and every author are untrusted text the origin wrote; this answer carries no `untrusted` field, the whole body is the origin's. A number that is not a whole number from 1 is a 404", |
| 1571 | |
auth: true, |
| 1572 | |
refusals: BROWSE_REFUSALS, |
| 1573 | |
}, |
| 1574 | |
{ |
| 1575 | |
method: "get", |
| 1576 | |
path: `${BROWSE}/pulls`, |
| 1577 | |
summary: |
| 1578 | |
"Browse an origin's pull requests live, through the origin you connected; answers { items, next_cursor } shaped like the issues list, with kind pull. Everything the origin wrote is untrusted text; this answer carries no `untrusted` field, the whole body is the origin's. state is open (default), closed or all; label, author and q narrow only the page in hand; cursor comes from next_cursor", |
| 1579 | |
auth: true, |
| 1580 | |
query: BROWSE_LIST_QUERY, |
| 1581 | |
refusals: BROWSE_REFUSALS, |
| 1582 | |
}, |
| 1583 | |
{ |
| 1584 | |
method: "get", |
| 1585 | |
path: `${BROWSE}/pulls/{number}`, |
| 1586 | |
summary: |
| 1587 | |
"Browse one pull request on an origin live; answers the pull row plus { body, comments, pull: { baseBranch, headBranch, baseOid, headOid, mergedAt, draft, diff, mergeable: null, conflicts, proposal: null, reviews } }. The body, comments, reviews and every author are untrusted text the origin wrote; this answer carries no `untrusted` field, the whole body is the origin's", |
| 1588 | |
auth: true, |
| 1589 | |
refusals: BROWSE_REFUSALS, |
| 1590 | |
}, |
| 1591 | |
{ |
| 1592 | |
method: "post", |
| 1593 | |
path: "/mcp", |
| 1594 | |
summary: "The forge as an MCP server (JSON-RPC 2.0); see docs/MCP.md", |
| 1595 | |
auth: true, |
| 1596 | |
body: { jsonrpc: "string", id: "integer", method: "string", params: "object" }, |
| 1597 | |
}, |
| 1598 | |
]; |
| 1599 | |
|
| 1600 | 25 |
const schema = (t: string) => (t === "object" ? { type: "object" } : t === "array" ? { type: "array", items: { type: "object" } } : { type: t }); |
| 1601 | 26 |
|
| 1602 | 27 |
|
|
|
@@ -1623,7 +48,7 @@ export function openapi(origin: string, version: string): Record<string, unknown |
| 1623 | 48 |
: schema(t), |
| 1624 | 49 |
})); |
| 1625 | 50 |
(paths[r.path] ??= {})[r.method] = { |
| 1626 | |
operationId: `${r.method}${r.path.replace(/[^A-Za-z0-9]+(.)?/g, (_, c: string | undefined) => (c ?? "").toUpperCase())}`, |
| 51 |
operationId: operationId(r), |
| 1627 | 52 |
summary: r.summary, |
| 1628 | 53 |
parameters: [...params, ...query], |
| 1629 | 54 |
...(r.body === undefined |