Public: anyone can read this; the crew can change it.
A repository's following routes leave api.ts for src/http/api/following.ts #7007
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6328-a-repository-s-following-routes-leave-ap → main 2 commits fork
2 files
changed +247
−226
src/http/api.ts +5 −226
| @@ -19,14 +19,12 @@ import { | ||
| 19 | 19 | } from "../actions/origin_app_words.ts"; |
| 20 | 20 | import { getProvenance, provenanceFacts } from "../core/provenance.ts"; |
| 21 | 21 | import { goldenPath } from "../apps/golden_path.ts"; |
| 22 | import { upstreamPayload } from "../spawn/upstream.ts"; | |
| 23 | 22 | import { setRole } from "../actions/roles.ts"; |
| 24 | 23 | import { listPeople, NOT_A_MEMBER } from "../core/roles.ts"; |
| 25 | 24 | import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts"; |
| 26 | 25 | import { storageLimitMessage } from "../core/storage.ts"; |
| 27 | 26 | import { voteVariant } from "../actions/variants.ts"; |
| 28 | 27 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts"; |
| 29 | import { parentForgeFor } from "../core/parent_releases.ts"; | |
| 30 | 28 | import { readFlow } from "../flow/read.ts"; |
| 31 | 29 | import { MILESTONE_WATCH_PERSON_ONLY, VOTE_NOT_RECORDED } from "../actions/board_words.ts"; |
| 32 | 30 | import { recordDrill } from "../core/domain_drills.ts"; |
| @@ -79,14 +77,10 @@ import { PRIVATE_ADDRESS_REFUSED } from "../kernel/address_words.ts"; | ||
| 79 | 77 | import { forgeState, PARENT_HEADER } from "../spawn/clients.ts"; |
| 80 | 78 | import { familyView } from "../spawn/family.ts"; |
| 81 | 79 | import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts"; |
| 82 | import { forgeProposals, proposeToParentForge, proposeUpstream } from "../actions/upstream.ts"; | |
| 83 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords, takeOrTell } from "../actions/follow.ts"; | |
| 84 | import { relationOf } from "../actions/forks.ts"; | |
| 85 | import { chainOfRepo, forgeLinkOf } from "../spawn/chain.ts"; | |
| 80 | import { takeOrTell } from "../actions/follow.ts"; | |
| 86 | 81 | import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts"; |
| 87 | 82 | import { flowFor, historyFor, sourcesOut, systemFor, welcome } from "../system/telemetry.ts"; |
| 88 | import { convergeFork, convergeState } from "../crew/converge.ts"; | |
| 89 | import { markReaders, mayDirectCrew } from "../crew/direct.ts"; | |
| 83 | import { markReaders } from "../crew/direct.ts"; | |
| 90 | 84 | import { |
| 91 | 85 | addCommentThrough, |
| 92 | 86 | openIssueThrough, |
| @@ -417,6 +411,7 @@ import { egressGrantRoutes } from "./api/egress_grants.ts"; | ||
| 417 | 411 | import { forgeRoutes } from "./api/forges.ts"; |
| 418 | 412 | import { agentRoutes } from "./api/agents.ts"; |
| 419 | 413 | import { boardRoutes } from "./api/boards.ts"; |
| 414 | import { followingRoutes } from "./api/following.ts"; | |
| 420 | 415 | import { labelsRoutes } from "./api/labels.ts"; |
| 421 | 416 | import { orgRoutes } from "./api/orgs.ts"; |
| 422 | 417 | import { secretRoutes } from "./api/secrets.ts"; |
| @@ -442,7 +437,7 @@ import { SPEND_WORDS } from "../actions/spend_words.ts"; | ||
| 442 | 437 | import { ANSWER_WORDS, ORDER_WORDS, SIGNUP_WORDS, VISIBILITY_WORDS } from "../core/arg_words.ts"; |
| 443 | 438 | |
| 444 | 439 | /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */ |
| 445 | async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> { | |
| 440 | export async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> { | |
| 446 | 441 | const took = await takeOrTell(ctx.app, ctx.auth.user, repo); |
| 447 | 442 | if (!took.ok) { |
| 448 | 443 | const e = took.error; |
| @@ -2247,223 +2242,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2247 | 2242 | return timePhase(ctx.request, "render", () => json(page)); |
| 2248 | 2243 | }); |
| 2249 | 2244 | |
| 2250 | // ── following. A fork's standing against its parent, read from git each | |
| 2251 | // time; taking updates is git's part, converging is the crew's. | |
| 2252 | router.add("GET", "/api/v1/repos/:owner/:name/following", async (ctx) => { | |
| 2253 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 2254 | const repo = loadRepo(ctx); | |
| 2255 | if (!repo) return notFound(); | |
| 2256 | const relation = await timePhaseAsync(ctx.request, "relation", () => | |
| 2257 | relationOf(ctx.app, ctx.auth.user, repo), | |
| 2258 | ); | |
| 2259 | const chain = await timePhaseAsync(ctx.request, "chain", () => chainOfRepo(ctx.app, repo)); | |
| 2260 | if (relation === undefined) { | |
| 2261 | if (chain === undefined) return apiError("not_a_fork", 404, "this repository follows nothing"); | |
| 2262 | const upstream = await timePhaseAsync(ctx.request, "upstream", () => upstreamPayload(ctx.app)); | |
| 2263 | return timePhase(ctx.request, "render", () => | |
| 2264 | json({ chain: chain.lines, severedAt: chain.severedAt, upstream }), | |
| 2265 | ); | |
| 2266 | } | |
| 2267 | const converge = | |
| 2268 | relation.state === "diverged" && !relation.clean | |
| 2269 | ? timePhase(ctx.request, "converge", () => convergeState(ctx.app, repo, relation.parent)) | |
| 2270 | : undefined; | |
| 2271 | const upstream = | |
| 2272 | chain === undefined | |
| 2273 | ? null | |
| 2274 | : await timePhaseAsync(ctx.request, "upstream", () => upstreamPayload(ctx.app)); | |
| 2275 | return timePhase(ctx.request, "render", () => json({ | |
| 2276 | chain: chain?.lines ?? null, | |
| 2277 | severedAt: chain?.severedAt ?? null, | |
| 2278 | upstream, | |
| 2279 | parent: `${relation.parent.ownerName}/${relation.parent.name}`, | |
| 2280 | parentBranch: relation.parent.defaultBranch, | |
| 2281 | // A parent whose origin was disconnected: the counts below are against | |
| 2282 | // where it froze, never against its upstream (#1090). | |
| 2283 | parentFrozen: | |
| 2284 | relation.parent.detached === null | |
| 2285 | ? null | |
| 2286 | : { | |
| 2287 | detachedAt: relation.parent.detached.detachedAt, | |
| 2288 | frozenSince: relation.parent.detached.frozenSince, | |
| 2289 | }, | |
| 2290 | branch: repo.defaultBranch, | |
| 2291 | state: relation.state, | |
| 2292 | behind: relation.behind, | |
| 2293 | ahead: relation.ahead, | |
| 2294 | clean: relation.clean, | |
| 2295 | conflicts: relation.conflicts, | |
| 2296 | forkTip: relation.forkTip, | |
| 2297 | parentTip: relation.parentTip, | |
| 2298 | parentForge: parentForgeFor(ctx.app.config), | |
| 2299 | converge: | |
| 2300 | converge === undefined | |
| 2301 | ? null | |
| 2302 | : { | |
| 2303 | kind: converge.kind, | |
| 2304 | issue: "issue" in converge ? converge.issue.number : null, | |
| 2305 | pull: "pull" in converge ? converge.pull.number : null, | |
| 2306 | agent: | |
| 2307 | "agent" in converge && converge.agent !== undefined | |
| 2308 | ? converge.agent.user.username | |
| 2309 | : null, | |
| 2310 | }, | |
| 2311 | })); | |
| 2312 | }); | |
| 2313 | ||
| 2314 | router.add( | |
| 2315 | "POST", | |
| 2316 | "/api/v1/repos/:owner/:name/following/take", | |
| 2317 | async (ctx) => { | |
| 2318 | const denied = requireScope(ctx, "rw"); | |
| 2319 | if (denied) return denied; | |
| 2320 | const repo = loadRepo(ctx); | |
| 2321 | if (!repo) return notFound(); | |
| 2322 | // The repository that carries this forge's code takes as a pull; any other fork merges. | |
| 2323 | if ((await forgeLinkOf(ctx.app))?.repo !== `${repo.ownerName}/${repo.name}`) return respondTake(ctx, repo); | |
| 2324 | const took = await takeForgeCode(ctx.app, ctx.auth.user, repo); | |
| 2325 | if (!took.ok) | |
| 2326 | return took.error === "not_following" | |
| 2327 | ? apiError("not_following", 404, FAMILY_WORDS.not_following) | |
| 2328 | : respondAccess(took.error, 403); | |
| 2329 | return json({ ...took.value, ...takeForgeWords(took.value) }); | |
| 2330 | }, | |
| 2331 | ); | |
| 2332 | ||
| 2333 | router.add( | |
| 2334 | "POST", | |
| 2335 | "/api/v1/repos/:owner/:name/following/sever", | |
| 2336 | async (ctx) => { | |
| 2337 | const denied = requireScope(ctx, "rw"); | |
| 2338 | if (denied) return denied; | |
| 2339 | const repo = loadRepo(ctx); | |
| 2340 | if (!repo) return notFound(); | |
| 2341 | const cut = await severForge(ctx.app, ctx.auth.user, repo); | |
| 2342 | if (!cut.ok) | |
| 2343 | return cut.error === "not_following" | |
| 2344 | ? apiError("not_following", 404, FAMILY_WORDS.not_following) | |
| 2345 | : respondAccess(cut.error, 403); | |
| 2346 | return json({ severedAt: cut.value }); | |
| 2347 | }, | |
| 2348 | ); | |
| 2349 | ||
| 2350 | // How this forge follows the forge its code came from: its own code | |
| 2351 | // repository's admin only, the same words as the page and MCP. | |
| 2352 | router.add("PATCH", "/api/v1/forge/following", async (ctx) => { | |
| 2353 | const denied = requireScope(ctx, "rw"); | |
| 2354 | if (denied) return denied; | |
| 2355 | const link = await forgeLinkOf(ctx.app); | |
| 2356 | const [owner, name] = (link?.repo ?? "/").split("/") as [string, string]; | |
| 2357 | const repo = link === undefined ? undefined : getRepo(ctx.app.db, owner, name); | |
| 2358 | if (repo === undefined) return apiError("not_following", 404, FOLLOW_MODE_REFUSALS.not_following.message); | |
| 2359 | const set = await setFollowMode(ctx.app, ctx.auth.user, repo, (await body(ctx.request)).mode); | |
| 2360 | if (set.ok) return json({ mode: set.value }); | |
| 2361 | const r = FOLLOW_MODE_REFUSALS[set.error]; | |
| 2362 | return apiError(set.error, r.status, r.message); | |
| 2363 | }); | |
| 2364 | ||
| 2365 | router.add( | |
| 2366 | "POST", | |
| 2367 | "/api/v1/repos/:owner/:name/following/propose", | |
| 2368 | async (ctx) => { | |
| 2369 | const denied = requireScope(ctx, "rw"); | |
| 2370 | if (denied) return denied; | |
| 2371 | const repo = loadRepo(ctx); | |
| 2372 | if (!repo) return notFound(); | |
| 2373 | const proposed = await proposeUpstream(ctx.app, ctx.auth.user, repo); | |
| 2374 | if (!proposed.ok) { | |
| 2375 | const e = proposed.error; | |
| 2376 | if (e === "not_a_fork") | |
| 2377 | return apiError("not_a_fork", 404, "this repository follows nothing"); | |
| 2378 | if (e === "nothing_to_propose") | |
| 2379 | return apiError( | |
| 2380 | "nothing_to_propose", | |
| 2381 | 409, | |
| 2382 | "nothing here is ahead of the parent", | |
| 2383 | ); | |
| 2384 | if (typeof e === "string") return respondAccess(e, 403); | |
| 2385 | return apiError("refused", 409, e.reason); | |
| 2386 | } | |
| 2387 | const { pull, opened, changes } = proposed.value; | |
| 2388 | return json({ number: pull.number, opened, changes, title: pull.title }); | |
| 2389 | }, | |
| 2390 | ); | |
| 2391 | ||
| 2392 | // Across forges: push this forge's change to the parent forge and open the | |
| 2393 | // pull there; GET says how each proposal stands, as the parent last said. | |
| 2394 | router.add( | |
| 2395 | "POST", | |
| 2396 | "/api/v1/repos/:owner/:name/following/propose-parent", | |
| 2397 | async (ctx) => { | |
| 2398 | const denied = requireScope(ctx, "rw"); | |
| 2399 | if (denied) return denied; | |
| 2400 | const repo = loadRepo(ctx); | |
| 2401 | if (!repo) return notFound(); | |
| 2402 | const made = await proposeToParentForge(ctx.app, ctx.auth.user, repo); | |
| 2403 | if (!made.ok) { | |
| 2404 | const e = made.error; | |
| 2405 | if (e === "no_parent_forge") | |
| 2406 | return apiError("no_parent_forge", 404, "this repository has no parent forge to propose to"); | |
| 2407 | if (e === "nothing_to_propose") | |
| 2408 | return apiError("nothing_to_propose", 409, FAMILY_WORDS.nothing_to_propose); | |
| 2409 | if (typeof e === "string") return respondAccess(e, 403); | |
| 2410 | return e.kind === "unreachable" | |
| 2411 | ? apiError("parent_unreachable", 502, "parent unreachable") | |
| 2412 | : apiError("refused", 409, e.reason); | |
| 2413 | } | |
| 2414 | return json(made.value, made.value.opened ? 201 : 200); | |
| 2415 | }, | |
| 2416 | ); | |
| 2417 | ||
| 2418 | router.add("GET", "/api/v1/repos/:owner/:name/following/proposals", async (ctx) => { | |
| 2419 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 2420 | const repo = loadRepo(ctx); | |
| 2421 | if (!repo) return notFound(); | |
| 2422 | const items = await timePhaseAsync(ctx.request, "read", () => | |
| 2423 | forgeProposals(ctx.app, repo, Date.now(), ctx.auth.user !== undefined), | |
| 2424 | ); | |
| 2425 | return timePhase(ctx.request, "render", () => json({ items })); | |
| 2426 | }); | |
| 2427 | ||
| 2428 | router.add( | |
| 2429 | "POST", | |
| 2430 | "/api/v1/repos/:owner/:name/following/converge", | |
| 2431 | async (ctx) => { | |
| 2432 | const denied = requireScope(ctx, "rw"); | |
| 2433 | if (denied) return denied; | |
| 2434 | const repo = loadRepo(ctx); | |
| 2435 | if (!repo) return notFound(); | |
| 2436 | if (!mayDirectCrew(ctx.app.db, repo, ctx.auth.user)) | |
| 2437 | return respondAccess("forbidden", 403); | |
| 2438 | const parent = forkParentFor(ctx.app.db, repo, ctx.auth.user); | |
| 2439 | if (parent === undefined) | |
| 2440 | return apiError("not_a_fork", 404, "this repository follows nothing"); | |
| 2441 | const outcome = await convergeFork(ctx.app, repo, parent, ctx.auth.user); | |
| 2442 | if (outcome.kind === "archived") return respondAccess("archived", 409); | |
| 2443 | const status = | |
| 2444 | outcome.kind === "queued" | |
| 2445 | ? 202 | |
| 2446 | : outcome.kind === "working" || outcome.kind === "proposed" | |
| 2447 | ? 200 | |
| 2448 | : outcome.kind === "ceiling" | |
| 2449 | ? 429 | |
| 2450 | : outcome.kind === "nobody" || outcome.kind === "forbidden" || outcome.kind === "not_member" | |
| 2451 | ? 403 | |
| 2452 | : 409; | |
| 2453 | return json( | |
| 2454 | { | |
| 2455 | kind: outcome.kind, | |
| 2456 | issue: "issue" in outcome ? outcome.issue.number : null, | |
| 2457 | pull: "pull" in outcome ? outcome.pull.number : null, | |
| 2458 | agent: | |
| 2459 | "agent" in outcome && outcome.agent !== undefined | |
| 2460 | ? outcome.agent.user.username | |
| 2461 | : null, | |
| 2462 | }, | |
| 2463 | status, | |
| 2464 | ); | |
| 2465 | }, | |
| 2466 | ); | |
| 2245 | followingRoutes(router); | |
| 2467 | 2246 | |
| 2468 | 2247 | // ── git reads. The agent arena showed the write path was complete but the |
| 2469 | 2248 | // read path was missing, so automation couldn't inspect before acting. |
src/http/api/following.ts added +242
| @@ -0,0 +1,242 @@ | ||
| 1 | /** | |
| 2 | * A fork's standing against its parent over the API: the | |
| 3 | * `/api/v1/repos/:owner/:name/following` routes and `PATCH | |
| 4 | * /api/v1/forge/following`. Moved out of `api.ts` (#6328); the rules live in | |
| 5 | * `actions/follow.ts` and `crew/converge.ts`. | |
| 6 | */ | |
| 7 | ||
| 8 | import { FAMILY_WORDS } from "../../actions/family_words.ts"; | |
| 9 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts"; | |
| 10 | import { relationOf } from "../../actions/forks.ts"; | |
| 11 | import { forgeProposals, proposeToParentForge, proposeUpstream } from "../../actions/upstream.ts"; | |
| 12 | import { parentForgeFor } from "../../core/parent_releases.ts"; | |
| 13 | import { forkParentFor, getRepo } from "../../core/repos.ts"; | |
| 14 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 15 | import { convergeFork, convergeState } from "../../crew/converge.ts"; | |
| 16 | import { mayDirectCrew } from "../../crew/direct.ts"; | |
| 17 | import { chainOfRepo, forgeLinkOf } from "../../spawn/chain.ts"; | |
| 18 | import { upstreamPayload } from "../../spawn/upstream.ts"; | |
| 19 | import { loadRepo, respondTake } from "../api.ts"; | |
| 20 | import type { Handler } from "../context.ts"; | |
| 21 | import { apiError, body, json, notFound, requireScope, respondAccess } from "../respond.ts"; | |
| 22 | import type { Router } from "../router.ts"; | |
| 23 | ||
| 24 | export function followingRoutes(router: Router<Handler>): void { | |
| 25 | // ── following. A fork's standing against its parent, read from git each | |
| 26 | // time; taking updates is git's part, converging is the crew's. | |
| 27 | router.add("GET", "/api/v1/repos/:owner/:name/following", async (ctx) => { | |
| 28 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 29 | const repo = loadRepo(ctx); | |
| 30 | if (!repo) return notFound(); | |
| 31 | const relation = await timePhaseAsync(ctx.request, "relation", () => | |
| 32 | relationOf(ctx.app, ctx.auth.user, repo), | |
| 33 | ); | |
| 34 | const chain = await timePhaseAsync(ctx.request, "chain", () => chainOfRepo(ctx.app, repo)); | |
| 35 | if (relation === undefined) { | |
| 36 | if (chain === undefined) return apiError("not_a_fork", 404, "this repository follows nothing"); | |
| 37 | const upstream = await timePhaseAsync(ctx.request, "upstream", () => upstreamPayload(ctx.app)); | |
| 38 | return timePhase(ctx.request, "render", () => | |
| 39 | json({ chain: chain.lines, severedAt: chain.severedAt, upstream }), | |
| 40 | ); | |
| 41 | } | |
| 42 | const converge = | |
| 43 | relation.state === "diverged" && !relation.clean | |
| 44 | ? timePhase(ctx.request, "converge", () => convergeState(ctx.app, repo, relation.parent)) | |
| 45 | : undefined; | |
| 46 | const upstream = | |
| 47 | chain === undefined | |
| 48 | ? null | |
| 49 | : await timePhaseAsync(ctx.request, "upstream", () => upstreamPayload(ctx.app)); | |
| 50 | return timePhase(ctx.request, "render", () => json({ | |
| 51 | chain: chain?.lines ?? null, | |
| 52 | severedAt: chain?.severedAt ?? null, | |
| 53 | upstream, | |
| 54 | parent: `${relation.parent.ownerName}/${relation.parent.name}`, | |
| 55 | parentBranch: relation.parent.defaultBranch, | |
| 56 | // A parent whose origin was disconnected: the counts below are against | |
| 57 | // where it froze, never against its upstream (#1090). | |
| 58 | parentFrozen: | |
| 59 | relation.parent.detached === null | |
| 60 | ? null | |
| 61 | : { | |
| 62 | detachedAt: relation.parent.detached.detachedAt, | |
| 63 | frozenSince: relation.parent.detached.frozenSince, | |
| 64 | }, | |
| 65 | branch: repo.defaultBranch, | |
| 66 | state: relation.state, | |
| 67 | behind: relation.behind, | |
| 68 | ahead: relation.ahead, | |
| 69 | clean: relation.clean, | |
| 70 | conflicts: relation.conflicts, | |
| 71 | forkTip: relation.forkTip, | |
| 72 | parentTip: relation.parentTip, | |
| 73 | parentForge: parentForgeFor(ctx.app.config), | |
| 74 | converge: | |
| 75 | converge === undefined | |
| 76 | ? null | |
| 77 | : { | |
| 78 | kind: converge.kind, | |
| 79 | issue: "issue" in converge ? converge.issue.number : null, | |
| 80 | pull: "pull" in converge ? converge.pull.number : null, | |
| 81 | agent: | |
| 82 | "agent" in converge && converge.agent !== undefined | |
| 83 | ? converge.agent.user.username | |
| 84 | : null, | |
| 85 | }, | |
| 86 | })); | |
| 87 | }); | |
| 88 | ||
| 89 | router.add( | |
| 90 | "POST", | |
| 91 | "/api/v1/repos/:owner/:name/following/take", | |
| 92 | async (ctx) => { | |
| 93 | const denied = requireScope(ctx, "rw"); | |
| 94 | if (denied) return denied; | |
| 95 | const repo = loadRepo(ctx); | |
| 96 | if (!repo) return notFound(); | |
| 97 | // The repository that carries this forge's code takes as a pull; any other fork merges. | |
| 98 | if ((await forgeLinkOf(ctx.app))?.repo !== `${repo.ownerName}/${repo.name}`) return respondTake(ctx, repo); | |
| 99 | const took = await takeForgeCode(ctx.app, ctx.auth.user, repo); | |
| 100 | if (!took.ok) | |
| 101 | return took.error === "not_following" | |
| 102 | ? apiError("not_following", 404, FAMILY_WORDS.not_following) | |
| 103 | : respondAccess(took.error, 403); | |
| 104 | return json({ ...took.value, ...takeForgeWords(took.value) }); | |
| 105 | }, | |
| 106 | ); | |
| 107 | ||
| 108 | router.add( | |
| 109 | "POST", | |
| 110 | "/api/v1/repos/:owner/:name/following/sever", | |
| 111 | async (ctx) => { | |
| 112 | const denied = requireScope(ctx, "rw"); | |
| 113 | if (denied) return denied; | |
| 114 | const repo = loadRepo(ctx); | |
| 115 | if (!repo) return notFound(); | |
| 116 | const cut = await severForge(ctx.app, ctx.auth.user, repo); | |
| 117 | if (!cut.ok) | |
| 118 | return cut.error === "not_following" | |
| 119 | ? apiError("not_following", 404, FAMILY_WORDS.not_following) | |
| 120 | : respondAccess(cut.error, 403); | |
| 121 | return json({ severedAt: cut.value }); | |
| 122 | }, | |
| 123 | ); | |
| 124 | ||
| 125 | // How this forge follows the forge its code came from: its own code | |
| 126 | // repository's admin only, the same words as the page and MCP. | |
| 127 | router.add("PATCH", "/api/v1/forge/following", async (ctx) => { | |
| 128 | const denied = requireScope(ctx, "rw"); | |
| 129 | if (denied) return denied; | |
| 130 | const link = await forgeLinkOf(ctx.app); | |
| 131 | const [owner, name] = (link?.repo ?? "/").split("/") as [string, string]; | |
| 132 | const repo = link === undefined ? undefined : getRepo(ctx.app.db, owner, name); | |
| 133 | if (repo === undefined) return apiError("not_following", 404, FOLLOW_MODE_REFUSALS.not_following.message); | |
| 134 | const set = await setFollowMode(ctx.app, ctx.auth.user, repo, (await body(ctx.request)).mode); | |
| 135 | if (set.ok) return json({ mode: set.value }); | |
| 136 | const r = FOLLOW_MODE_REFUSALS[set.error]; | |
| 137 | return apiError(set.error, r.status, r.message); | |
| 138 | }); | |
| 139 | ||
| 140 | router.add( | |
| 141 | "POST", | |
| 142 | "/api/v1/repos/:owner/:name/following/propose", | |
| 143 | async (ctx) => { | |
| 144 | const denied = requireScope(ctx, "rw"); | |
| 145 | if (denied) return denied; | |
| 146 | const repo = loadRepo(ctx); | |
| 147 | if (!repo) return notFound(); | |
| 148 | const proposed = await proposeUpstream(ctx.app, ctx.auth.user, repo); | |
| 149 | if (!proposed.ok) { | |
| 150 | const e = proposed.error; | |
| 151 | if (e === "not_a_fork") | |
| 152 | return apiError("not_a_fork", 404, "this repository follows nothing"); | |
| 153 | if (e === "nothing_to_propose") | |
| 154 | return apiError( | |
| 155 | "nothing_to_propose", | |
| 156 | 409, | |
| 157 | "nothing here is ahead of the parent", | |
| 158 | ); | |
| 159 | if (typeof e === "string") return respondAccess(e, 403); | |
| 160 | return apiError("refused", 409, e.reason); | |
| 161 | } | |
| 162 | const { pull, opened, changes } = proposed.value; | |
| 163 | return json({ number: pull.number, opened, changes, title: pull.title }); | |
| 164 | }, | |
| 165 | ); | |
| 166 | ||
| 167 | // Across forges: push this forge's change to the parent forge and open the | |
| 168 | // pull there; GET says how each proposal stands, as the parent last said. | |
| 169 | router.add( | |
| 170 | "POST", | |
| 171 | "/api/v1/repos/:owner/:name/following/propose-parent", | |
| 172 | async (ctx) => { | |
| 173 | const denied = requireScope(ctx, "rw"); | |
| 174 | if (denied) return denied; | |
| 175 | const repo = loadRepo(ctx); | |
| 176 | if (!repo) return notFound(); | |
| 177 | const made = await proposeToParentForge(ctx.app, ctx.auth.user, repo); | |
| 178 | if (!made.ok) { | |
| 179 | const e = made.error; | |
| 180 | if (e === "no_parent_forge") | |
| 181 | return apiError("no_parent_forge", 404, "this repository has no parent forge to propose to"); | |
| 182 | if (e === "nothing_to_propose") | |
| 183 | return apiError("nothing_to_propose", 409, FAMILY_WORDS.nothing_to_propose); | |
| 184 | if (typeof e === "string") return respondAccess(e, 403); | |
| 185 | return e.kind === "unreachable" | |
| 186 | ? apiError("parent_unreachable", 502, "parent unreachable") | |
| 187 | : apiError("refused", 409, e.reason); | |
| 188 | } | |
| 189 | return json(made.value, made.value.opened ? 201 : 200); | |
| 190 | }, | |
| 191 | ); | |
| 192 | ||
| 193 | router.add("GET", "/api/v1/repos/:owner/:name/following/proposals", async (ctx) => { | |
| 194 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 195 | const repo = loadRepo(ctx); | |
| 196 | if (!repo) return notFound(); | |
| 197 | const items = await timePhaseAsync(ctx.request, "read", () => | |
| 198 | forgeProposals(ctx.app, repo, Date.now(), ctx.auth.user !== undefined), | |
| 199 | ); | |
| 200 | return timePhase(ctx.request, "render", () => json({ items })); | |
| 201 | }); | |
| 202 | ||
| 203 | router.add( | |
| 204 | "POST", | |
| 205 | "/api/v1/repos/:owner/:name/following/converge", | |
| 206 | async (ctx) => { | |
| 207 | const denied = requireScope(ctx, "rw"); | |
| 208 | if (denied) return denied; | |
| 209 | const repo = loadRepo(ctx); | |
| 210 | if (!repo) return notFound(); | |
| 211 | if (!mayDirectCrew(ctx.app.db, repo, ctx.auth.user)) | |
| 212 | return respondAccess("forbidden", 403); | |
| 213 | const parent = forkParentFor(ctx.app.db, repo, ctx.auth.user); | |
| 214 | if (parent === undefined) | |
| 215 | return apiError("not_a_fork", 404, "this repository follows nothing"); | |
| 216 | const outcome = await convergeFork(ctx.app, repo, parent, ctx.auth.user); | |
| 217 | if (outcome.kind === "archived") return respondAccess("archived", 409); | |
| 218 | const status = | |
| 219 | outcome.kind === "queued" | |
| 220 | ? 202 | |
| 221 | : outcome.kind === "working" || outcome.kind === "proposed" | |
| 222 | ? 200 | |
| 223 | : outcome.kind === "ceiling" | |
| 224 | ? 429 | |
| 225 | : outcome.kind === "nobody" || outcome.kind === "forbidden" || outcome.kind === "not_member" | |
| 226 | ? 403 | |
| 227 | : 409; | |
| 228 | return json( | |
| 229 | { | |
| 230 | kind: outcome.kind, | |
| 231 | issue: "issue" in outcome ? outcome.issue.number : null, | |
| 232 | pull: "pull" in outcome ? outcome.pull.number : null, | |
| 233 | agent: | |
| 234 | "agent" in outcome && outcome.agent !== undefined | |
| 235 | ? outcome.agent.user.username | |
| 236 | : null, | |
| 237 | }, | |
| 238 | status, | |
| 239 | ); | |
| 240 | }, | |
| 241 | ); | |
| 242 | } |
new above
offline