Public: anyone can read this; the crew can change it.
A repository's upstream and forks routes leave api.ts for src/http/api/upstream.ts #6932
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6305-a-repository-s-upstream-and-forks-routes → main 2 commits fork
3 files
changed +109
−89
src/http/api.ts +4 −89
| @@ -9,14 +9,7 @@ | ||
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { homeTruth } from "../system/home_truth.ts"; |
| 12 | import { | |
| 13 | checkUpstreamNow, | |
| 14 | FOLLOW_REFUSALS, | |
| 15 | followJson, | |
| 16 | upstreamFacts, | |
| 17 | setFollowUpstream, | |
| 18 | } from "../actions/upstream_follow.ts"; | |
| 19 | import { getFollow } from "../core/upstream_follow.ts"; | |
| 12 | import { upstreamFacts } from "../actions/upstream_follow.ts"; | |
| 20 | 13 | import { FAMILY_WORDS } from "../actions/family_words.ts"; |
| 21 | 14 | import { showProvenance } from "../actions/provenance.ts"; |
| 22 | 15 | import { |
| @@ -160,8 +153,6 @@ import { | ||
| 160 | 153 | createWebhook, |
| 161 | 154 | deleteRepo, |
| 162 | 155 | deleteWebhook, |
| 163 | FORKS_PER_DAY, | |
| 164 | forkRepo, | |
| 165 | 156 | repositoryLimitMessage, |
| 166 | 157 | resolveOwner, |
| 167 | 158 | privateRepoForSealed, |
| @@ -223,7 +214,6 @@ import { | ||
| 223 | 214 | getRepo, |
| 224 | 215 | listReposByOwner, |
| 225 | 216 | listReposVisibleTo, |
| 226 | visibleForks, | |
| 227 | 217 | type Repo, |
| 228 | 218 | type RepoCounts, |
| 229 | 219 | canWrite, |
| @@ -437,6 +427,7 @@ import { boardRoutes } from "./api/boards.ts"; | ||
| 437 | 427 | import { labelsRoutes } from "./api/labels.ts"; |
| 438 | 428 | import { orgRoutes } from "./api/orgs.ts"; |
| 439 | 429 | import { secretRoutes } from "./api/secrets.ts"; |
| 430 | import { upstreamRoutes } from "./api/upstream.ts"; | |
| 440 | 431 | import type { Router } from "./router.ts"; |
| 441 | 432 | import { clientAddress, clientBucket } from "./peer.ts"; |
| 442 | 433 | import { manifestFor } from "../actions/here.ts"; |
| @@ -455,7 +446,7 @@ import { REPORT_RECEIVED } from "../actions/report_words.ts"; | ||
| 455 | 446 | import { ADMIN_WORDS } from "../actions/admin_words.ts"; |
| 456 | 447 | import { SETTING_WORDS } from "../actions/setting_words.ts"; |
| 457 | 448 | import { SPEND_WORDS } from "../actions/spend_words.ts"; |
| 458 | import { ANSWER_WORDS, ENABLED_WORDS, ORDER_WORDS, SIGNUP_WORDS, VISIBILITY_WORDS } from "../core/arg_words.ts"; | |
| 449 | import { ANSWER_WORDS, ORDER_WORDS, SIGNUP_WORDS, VISIBILITY_WORDS } from "../core/arg_words.ts"; | |
| 459 | 450 | |
| 460 | 451 | /** Take the parent's updates for `repo` and answer as the API does, for every route that asks. */ |
| 461 | 452 | async function respondTake(ctx: Ctx, repo: Repo): Promise<Response> { |
| @@ -2128,52 +2119,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2128 | 2119 | return res; |
| 2129 | 2120 | }); |
| 2130 | 2121 | |
| 2131 | // Following an outside upstream (#3442): an admin reads, sets and checks. | |
| 2132 | router.add("GET", "/api/v1/repos/:owner/:name/upstream", (ctx) => { | |
| 2133 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 2134 | const denied = requireScope(ctx, "ro"); | |
| 2135 | if (denied) return denied; | |
| 2136 | const read = timePhase(ctx.request, "upstream", () => { | |
| 2137 | const repo = loadRepo(ctx); | |
| 2138 | if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return undefined; | |
| 2139 | return getFollow(ctx.app.db, repo.id) ?? null; | |
| 2140 | }); | |
| 2141 | if (read === undefined) return notFound(); | |
| 2142 | return timePhase(ctx.request, "render", () => json({ following: read === null ? null : followJson(read) })); | |
| 2143 | }); | |
| 2144 | ||
| 2145 | router.add("PUT", "/api/v1/repos/:owner/:name/upstream", async (ctx) => { | |
| 2146 | const denied = requireScope(ctx, "admin"); | |
| 2147 | if (denied) return denied; | |
| 2148 | const repo = loadRepo(ctx); | |
| 2149 | if (!repo) return notFound(); | |
| 2150 | const input = await body(ctx.request); | |
| 2151 | if (typeof input.enabled !== "boolean") | |
| 2152 | return apiError("bad_enabled", 422, ENABLED_WORDS); | |
| 2153 | const saved = setFollowUpstream(ctx.app, ctx.auth.user, repo, { | |
| 2154 | url: str(input.url), | |
| 2155 | branch: str(input.branch), | |
| 2156 | enabled: input.enabled, | |
| 2157 | }); | |
| 2158 | if (!saved.ok) | |
| 2159 | return saved.error === "not_found" || saved.error === "unauthorized" || saved.error === "forbidden" | |
| 2160 | ? respondAccess(saved.error, 422) | |
| 2161 | : apiError(saved.error, 422, FOLLOW_REFUSALS[saved.error]); | |
| 2162 | return json({ following: followJson(saved.value) }); | |
| 2163 | }); | |
| 2164 | ||
| 2165 | router.add("POST", "/api/v1/repos/:owner/:name/upstream/check", async (ctx) => { | |
| 2166 | const denied = requireScope(ctx, "admin"); | |
| 2167 | if (denied) return denied; | |
| 2168 | const repo = loadRepo(ctx); | |
| 2169 | if (!repo) return notFound(); | |
| 2170 | const checked = await checkUpstreamNow(ctx.app, ctx.auth.user, repo); | |
| 2171 | if (!checked.ok) | |
| 2172 | return checked.error === "not_found" || checked.error === "unauthorized" || checked.error === "forbidden" | |
| 2173 | ? respondAccess(checked.error, 422) | |
| 2174 | : apiError(checked.error, checked.error === "busy" ? 409 : 422, FOLLOW_REFUSALS[checked.error]); | |
| 2175 | return json(checked.value); | |
| 2176 | }); | |
| 2122 | upstreamRoutes(router); | |
| 2177 | 2123 | |
| 2178 | 2124 | router.add("PATCH", "/api/v1/repos/:owner/:name", async (ctx) => { |
| 2179 | 2125 | const denied = requireScope(ctx, "admin"); |
| @@ -2280,37 +2226,6 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2280 | 2226 | return timePhase(ctx.request, "render", () => json(page)); |
| 2281 | 2227 | }); |
| 2282 | 2228 | |
| 2283 | router.add("GET", "/api/v1/repos/:owner/:name/forks", (ctx) => { | |
| 2284 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 2285 | const repo = loadRepo(ctx); | |
| 2286 | if (!repo) return notFound(); | |
| 2287 | const forks = timePhase(ctx.request, "forks", () => visibleForks(ctx.app.db, repo.id, ctx.auth.user)); | |
| 2288 | return timePhase(ctx.request, "render", () => json(forks)); | |
| 2289 | }); | |
| 2290 | ||
| 2291 | router.add("POST", "/api/v1/repos/:owner/:name/forks", async (ctx) => { | |
| 2292 | const denied = requireScope(ctx, "rw"); | |
| 2293 | if (denied) return denied; | |
| 2294 | const parent = loadRepo(ctx); | |
| 2295 | if (!parent) return notFound(); | |
| 2296 | const input = await body(ctx.request); | |
| 2297 | const forked = await forkRepo( | |
| 2298 | ctx.app, | |
| 2299 | ctx.auth.user, | |
| 2300 | parent, | |
| 2301 | str(input.name) || undefined, | |
| 2302 | ); | |
| 2303 | if (!forked.ok) | |
| 2304 | return forked.error === "repository_limit" | |
| 2305 | ? apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)) | |
| 2306 | : forked.error === "storage_limit" | |
| 2307 | ? apiError("storage_limit", 403, storageLimitMessage(ctx.app, ctx.auth.user!.id)) | |
| 2308 | : forked.error === "limit" | |
| 2309 | ? apiError("limit", 429, `at most ${FORKS_PER_DAY} forks a day: try again tomorrow`) | |
| 2310 | : respondAccess(forked.error, 422); | |
| 2311 | return json(forked.value, 201); | |
| 2312 | }); | |
| 2313 | ||
| 2314 | 2229 | // ── following. A fork's standing against its parent, read from git each |
| 2315 | 2230 | // time; taking updates is git's part, converging is the crew's. |
| 2316 | 2231 | router.add("GET", "/api/v1/repos/:owner/:name/following", async (ctx) => { |
src/http/api/upstream.ts added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * A repository's upstream and forks over the API: `GET` and `PUT` | |
| 3 | * `/api/v1/repos/:owner/:name/upstream`, `POST …/upstream/check`, and `GET` | |
| 4 | * and `POST` `/api/v1/repos/:owner/:name/forks`. Moved out of `api.ts` | |
| 5 | * (#6305); the rules live in `actions/upstream_follow.ts` and | |
| 6 | * `actions/repos.ts`. | |
| 7 | */ | |
| 8 | ||
| 9 | import { forkRepo, FORKS_PER_DAY, repositoryLimitMessage } from "../../actions/repos.ts"; | |
| 10 | import { | |
| 11 | checkUpstreamNow, | |
| 12 | FOLLOW_REFUSALS, | |
| 13 | followJson, | |
| 14 | setFollowUpstream, | |
| 15 | } from "../../actions/upstream_follow.ts"; | |
| 16 | import { ENABLED_WORDS } from "../../core/arg_words.ts"; | |
| 17 | import { canAdmin, visibleForks } from "../../core/repos.ts"; | |
| 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; | |
| 19 | import { storageLimitMessage } from "../../core/storage.ts"; | |
| 20 | import { getFollow } from "../../core/upstream_follow.ts"; | |
| 21 | import { loadRepo } from "../api.ts"; | |
| 22 | import type { Handler } from "../context.ts"; | |
| 23 | import { apiError, body, json, notFound, requireScope, respondAccess, str } from "../respond.ts"; | |
| 24 | import type { Router } from "../router.ts"; | |
| 25 | ||
| 26 | export function upstreamRoutes(router: Router<Handler>): void { | |
| 27 | // Following an outside upstream (#3442): an admin reads, sets and checks. | |
| 28 | router.add("GET", "/api/v1/repos/:owner/:name/upstream", (ctx) => { | |
| 29 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 30 | const denied = requireScope(ctx, "ro"); | |
| 31 | if (denied) return denied; | |
| 32 | const read = timePhase(ctx.request, "upstream", () => { | |
| 33 | const repo = loadRepo(ctx); | |
| 34 | if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return undefined; | |
| 35 | return getFollow(ctx.app.db, repo.id) ?? null; | |
| 36 | }); | |
| 37 | if (read === undefined) return notFound(); | |
| 38 | return timePhase(ctx.request, "render", () => json({ following: read === null ? null : followJson(read) })); | |
| 39 | }); | |
| 40 | ||
| 41 | router.add("PUT", "/api/v1/repos/:owner/:name/upstream", async (ctx) => { | |
| 42 | const denied = requireScope(ctx, "admin"); | |
| 43 | if (denied) return denied; | |
| 44 | const repo = loadRepo(ctx); | |
| 45 | if (!repo) return notFound(); | |
| 46 | const input = await body(ctx.request); | |
| 47 | if (typeof input.enabled !== "boolean") | |
| 48 | return apiError("bad_enabled", 422, ENABLED_WORDS); | |
| 49 | const saved = setFollowUpstream(ctx.app, ctx.auth.user, repo, { | |
| 50 | url: str(input.url), | |
| 51 | branch: str(input.branch), | |
| 52 | enabled: input.enabled, | |
| 53 | }); | |
| 54 | if (!saved.ok) | |
| 55 | return saved.error === "not_found" || saved.error === "unauthorized" || saved.error === "forbidden" | |
| 56 | ? respondAccess(saved.error, 422) | |
| 57 | : apiError(saved.error, 422, FOLLOW_REFUSALS[saved.error]); | |
| 58 | return json({ following: followJson(saved.value) }); | |
| 59 | }); | |
| 60 | ||
| 61 | router.add("POST", "/api/v1/repos/:owner/:name/upstream/check", async (ctx) => { | |
| 62 | const denied = requireScope(ctx, "admin"); | |
| 63 | if (denied) return denied; | |
| 64 | const repo = loadRepo(ctx); | |
| 65 | if (!repo) return notFound(); | |
| 66 | const checked = await checkUpstreamNow(ctx.app, ctx.auth.user, repo); | |
| 67 | if (!checked.ok) | |
| 68 | return checked.error === "not_found" || checked.error === "unauthorized" || checked.error === "forbidden" | |
| 69 | ? respondAccess(checked.error, 422) | |
| 70 | : apiError(checked.error, checked.error === "busy" ? 409 : 422, FOLLOW_REFUSALS[checked.error]); | |
| 71 | return json(checked.value); | |
| 72 | }); | |
| 73 | ||
| 74 | router.add("GET", "/api/v1/repos/:owner/:name/forks", (ctx) => { | |
| 75 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 76 | const repo = loadRepo(ctx); | |
| 77 | if (!repo) return notFound(); | |
| 78 | const forks = timePhase(ctx.request, "forks", () => visibleForks(ctx.app.db, repo.id, ctx.auth.user)); | |
| 79 | return timePhase(ctx.request, "render", () => json(forks)); | |
| 80 | }); | |
| 81 | ||
| 82 | router.add("POST", "/api/v1/repos/:owner/:name/forks", async (ctx) => { | |
| 83 | const denied = requireScope(ctx, "rw"); | |
| 84 | if (denied) return denied; | |
| 85 | const parent = loadRepo(ctx); | |
| 86 | if (!parent) return notFound(); | |
| 87 | const input = await body(ctx.request); | |
| 88 | const forked = await forkRepo( | |
| 89 | ctx.app, | |
| 90 | ctx.auth.user, | |
| 91 | parent, | |
| 92 | str(input.name) || undefined, | |
| 93 | ); | |
| 94 | if (!forked.ok) | |
| 95 | return forked.error === "repository_limit" | |
| 96 | ? apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)) | |
| 97 | : forked.error === "storage_limit" | |
| 98 | ? apiError("storage_limit", 403, storageLimitMessage(ctx.app, ctx.auth.user!.id)) | |
| 99 | : forked.error === "limit" | |
| 100 | ? apiError("limit", 429, `at most ${FORKS_PER_DAY} forks a day: try again tomorrow`) | |
| 101 | : respondAccess(forked.error, 422); | |
| 102 | return json(forked.value, 201); | |
| 103 | }); | |
| 104 | } |
test/arg-words.test.ts +1
| @@ -15,6 +15,7 @@ const DEEP = "../../../core/arg_words.ts"; | ||
| 15 | 15 | /** Each file that refuses with these words, and how it imports them. */ |
| 16 | 16 | const DOORS: Record<string, string> = { |
| 17 | 17 | "src/http/api.ts": "../core/arg_words.ts", |
| 18 | "src/http/api/upstream.ts": "../../core/arg_words.ts", | |
| 18 | 19 | "src/http/feed.ts": "../core/arg_words.ts", |
| 19 | 20 | "src/http/mcp/tools/asked.ts": DEEP, |
| 20 | 21 | "src/http/mcp/tools/board_pins.ts": DEEP, |
new above
offline