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

Merged opened this

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

Merged

scout/forge:scout/6328-a-repository-s-following-routes-leave-ap → main 2 commits fork

  • Merged. scout/6328-a-repository-s-following-routes-leave-ap was merged into main as 5d671b4.

Review

Sign in to review this change.
2 files changed +247 −226
src/http/api.ts +5 −226
@@ -19,14 +19,12 @@ import {
1919 } from "../actions/origin_app_words.ts";
2020 import { getProvenance, provenanceFacts } from "../core/provenance.ts";
2121 import { goldenPath } from "../apps/golden_path.ts";
22 import { upstreamPayload } from "../spawn/upstream.ts";
2322 import { setRole } from "../actions/roles.ts";
2423 import { listPeople, NOT_A_MEMBER } from "../core/roles.ts";
2524 import { setAccountCeilings, setAccountGrant } from "../actions/model_grant.ts";
2625 import { storageLimitMessage } from "../core/storage.ts";
2726 import { voteVariant } from "../actions/variants.ts";
2827 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../core/route_timing.ts";
29 import { parentForgeFor } from "../core/parent_releases.ts";
3028 import { readFlow } from "../flow/read.ts";
3129 import { MILESTONE_WATCH_PERSON_ONLY, VOTE_NOT_RECORDED } from "../actions/board_words.ts";
3230 import { recordDrill } from "../core/domain_drills.ts";
@@ -79,14 +77,10 @@ import { PRIVATE_ADDRESS_REFUSED } from "../kernel/address_words.ts";
7977 import { forgeState, PARENT_HEADER } from "../spawn/clients.ts";
8078 import { familyView } from "../spawn/family.ts";
8179 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";
8681 import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts";
8782 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";
9084 import {
9185 addCommentThrough,
9286 openIssueThrough,
@@ -417,6 +411,7 @@ import { egressGrantRoutes } from "./api/egress_grants.ts";
417411 import { forgeRoutes } from "./api/forges.ts";
418412 import { agentRoutes } from "./api/agents.ts";
419413 import { boardRoutes } from "./api/boards.ts";
414 import { followingRoutes } from "./api/following.ts";
420415 import { labelsRoutes } from "./api/labels.ts";
421416 import { orgRoutes } from "./api/orgs.ts";
422417 import { secretRoutes } from "./api/secrets.ts";
@@ -442,7 +437,7 @@ import { SPEND_WORDS } from "../actions/spend_words.ts";
442437 import { ANSWER_WORDS, ORDER_WORDS, SIGNUP_WORDS, VISIBILITY_WORDS } from "../core/arg_words.ts";
443438
444439 /** 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> {
446441 const took = await takeOrTell(ctx.app, ctx.auth.user, repo);
447442 if (!took.ok) {
448443 const e = took.error;
@@ -2247,223 +2242,7 @@ export function apiRoutes(router: Router<Handler>): void {
22472242 return timePhase(ctx.request, "render", () => json(page));
22482243 });
22492244
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);
24672246
24682247 // ── git reads. The agent arena showed the write path was complete but the
24692248 // 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 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs