Public: anyone can read this; the crew can change it.
The chain page offers "Take the N new commits" in tell-me, opening the same take pull take-updates opens #2653
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2651-the-chain-page-offers-take-the-n-new-com → main 2 commits fork 2 threads · 2 unresolved
7 files
changed +180
−10
README.md +6
| @@ -194,6 +194,12 @@ record in `parent.json` is rewritten atomically and a `forge.follow_mode` event | ||
| 194 | 194 | is appended. A non-admin is refused (403), a value outside the two modes is |
| 195 | 195 | 422, and a severed forge is 409; each leaves `parent.json` untouched. |
| 196 | 196 | |
| 197 | **Take the N new commits** (shown to the admin on `/<org>/forge/following` when | |
| 198 | the parent is ahead, `POST /api/v1/repos/:owner/:name/following/take`, or `t` on | |
| 199 | the chain screen in the terminal) opens, or updates, the one `forge/take-updates` | |
| 200 | pull whatever the follow mode, and never moves the default branch; a conflict | |
| 201 | opens the converge issue instead, and nothing behind answers "Already up to date." | |
| 202 | ||
| 197 | 203 | The page and `GET …/following/proposals` show each proposal as open, merged or |
| 198 | 204 | closed, read from the parent (the token may read back only pulls it opened) and |
| 199 | 205 | cached for a minute (only a signed-in viewer makes the parent get asked); a merged or closed one is final. A parent that does not |
src/actions/follow.ts +35 −1
| @@ -12,8 +12,9 @@ import { convergeFork, type Converge } from "../crew/converge.ts"; | ||
| 12 | 12 | import { ARCHIVED_WHY, canAdmin, canWrite, forkParentFor, type Repo } from "../core/repos.ts"; |
| 13 | 13 | import type { User } from "../core/users.ts"; |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | import { chainOfRepo, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts"; | |
| 15 | import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts"; | |
| 16 | 16 | import { requireUser } from "./access.ts"; |
| 17 | import { takeFromParent, type Taken } from "../spawn/take.ts"; | |
| 17 | 18 | import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts"; |
| 18 | 19 | |
| 19 | 20 | export type TakeOrTell = Result<ForkSync & { merged: boolean }, TakeUpdatesError | Conflict>; |
| @@ -62,6 +63,39 @@ export async function severForge( | ||
| 62 | 63 | return ok(cut.value); |
| 63 | 64 | } |
| 64 | 65 | |
| 66 | export type TakeForgeResult = Taken | { readonly kind: "current" }; | |
| 67 | ||
| 68 | /** | |
| 69 | * Bring the parent's new code as the one take pull (`takeFromParent`), by | |
| 70 | * hand and whatever the mode: the repository's admin only. A conflict opens | |
| 71 | * the converge issue; nothing behind is `current`. The default branch never moves. | |
| 72 | */ | |
| 73 | export async function takeForgeCode( | |
| 74 | app: App, | |
| 75 | actor: User | undefined, | |
| 76 | repo: Repo, | |
| 77 | ): Promise<Result<TakeForgeResult, SeverError>> { | |
| 78 | const who = requireUser(actor); | |
| 79 | if (!who.ok) return err("unauthorized"); | |
| 80 | const link = await forgeLinkOf(app); | |
| 81 | if (link === undefined || link.severedAt !== undefined || link.repo !== `${repo.ownerName}/${repo.name}`) | |
| 82 | return err("not_following"); | |
| 83 | if (!canAdmin(app.db, repo, who.value) || !canWrite(app.db, repo, who.value)) return err("forbidden"); | |
| 84 | return ok((await takeFromParent(app, link)) ?? { kind: "current" }); | |
| 85 | } | |
| 86 | ||
| 87 | /** What a take did, in a sentence; `danger` for a conflict. */ | |
| 88 | export function takeForgeWords(r: TakeForgeResult): { tone?: "danger"; message: string } { | |
| 89 | if (r.kind === "pull") | |
| 90 | return { message: `${r.commits} commit${r.commits === 1 ? "" : "s"} from your parent ${r.commits === 1 ? "is" : "are"} in pull #${r.number}.` }; | |
| 91 | if (r.kind === "conflict") | |
| 92 | return { | |
| 93 | tone: "danger", | |
| 94 | message: `Your parent's new code conflicts in ${r.files.join(", ")}${r.issue === null ? "" : `; see issue #${r.issue}`}.`, | |
| 95 | }; | |
| 96 | return { message: "Already up to date." }; | |
| 97 | } | |
| 98 | ||
| 65 | 99 | export type FollowModeError = SeverError | "invalid_mode" | "severed"; |
| 66 | 100 | |
| 67 | 101 | /** The one answer every door gives a refused mode change, with the status an HTTP door sends. */ |
src/http/api.ts +9 −2
| @@ -63,7 +63,7 @@ import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts"; | ||
| 63 | 63 | import { reissueDoor, spawnDoor } from "../spawn/door.ts"; |
| 64 | 64 | import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts"; |
| 65 | 65 | import { forgeProposals, proposeToParentForge, proposeUpstream } from "../actions/upstream.ts"; |
| 66 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeOrTell } from "../actions/follow.ts"; | |
| 66 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords, takeOrTell } from "../actions/follow.ts"; | |
| 67 | 67 | import { relationOf } from "../actions/forks.ts"; |
| 68 | 68 | import { chainOfRepo, forgeLinkOf } from "../spawn/chain.ts"; |
| 69 | 69 | import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts"; |
| @@ -2336,7 +2336,14 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2336 | 2336 | if (denied) return denied; |
| 2337 | 2337 | const repo = loadRepo(ctx); |
| 2338 | 2338 | if (!repo) return notFound(); |
| 2339 | return respondTake(ctx, repo); | |
| 2339 | // The repository that carries this forge's code takes as a pull; any other fork merges. | |
| 2340 | if ((await forgeLinkOf(ctx.app))?.repo !== `${repo.ownerName}/${repo.name}`) return respondTake(ctx, repo); | |
| 2341 | const took = await takeForgeCode(ctx.app, ctx.auth.user, repo); | |
| 2342 | if (!took.ok) | |
| 2343 | return took.error === "not_following" | |
| 2344 | ? apiError("not_following", 404, "this repository follows nothing across forges") | |
| 2345 | : respondAccess(took.error, 403); | |
| 2346 | return json({ ...took.value, ...takeForgeWords(took.value) }); | |
| 2340 | 2347 | }, |
| 2341 | 2348 | ); |
| 2342 | 2349 |
src/http/server.ts +2 −1
| @@ -128,7 +128,7 @@ import { | ||
| 128 | 128 | signinPasskey, |
| 129 | 129 | } from "../web/pages/passkeys.ts"; |
| 130 | 130 | import { confirmPage, searchPage } from "../web/pages/search.ts"; |
| 131 | import { chainPage, checkSubmit, modeSubmit, proposeParentSubmit, severSubmit } from "../web/pages/chain.ts"; | |
| 131 | import { chainPage, checkSubmit, modeSubmit, proposeParentSubmit, severSubmit, takeSubmit } from "../web/pages/chain.ts"; | |
| 132 | 132 | import { followingSubmit } from "../web/pages/following.ts"; |
| 133 | 133 | import { reconnectSubmit } from "../web/pages/frozen.ts"; |
| 134 | 134 | import { activityAtom } from "../web/feeds.ts"; |
| @@ -1273,6 +1273,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1273 | 1273 | router.add("GET", "/:owner/:name/following", chainPage); |
| 1274 | 1274 | router.add("POST", "/:owner/:name/following/sever", severSubmit); |
| 1275 | 1275 | router.add("POST", "/:owner/:name/following/mode", modeSubmit); |
| 1276 | router.add("POST", "/:owner/:name/following/take", takeSubmit); | |
| 1276 | 1277 | router.add("POST", "/:owner/:name/following/propose", proposeParentSubmit); |
| 1277 | 1278 | router.add("POST", "/:owner/:name/following/check", checkSubmit); |
| 1278 | 1279 | // A mirror pulls on demand; a pin says "keep this on my machine". Both are |
src/tui/screens/chain.ts +12 −2
| @@ -2,11 +2,11 @@ | ||
| 2 | 2 | * The chain of forges this forge's code descends from, in a card. The gate is |
| 3 | 3 | * the page's: only someone who can read `<org>/forge` sees it, and only that |
| 4 | 4 | * repository's owner is offered Sever, which asks for the same typed word, |
| 5 | * and `m`, which flips the follow mode. | |
| 5 | * `m`, which flips the follow mode, and `t`, which takes the new commits as a pull. | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import type { App as Forge } from "../../app.ts"; |
| 9 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge } from "../../actions/follow.ts"; | |
| 9 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts"; | |
| 10 | 10 | import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../../actions/upstream.ts"; |
| 11 | 11 | import { checkUpstream, upstreamState, upstreamWords } from "../../spawn/upstream.ts"; |
| 12 | 12 | import { parentCredential } from "../../spawn/parent_credential.ts"; |
| @@ -50,12 +50,15 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): | ||
| 50 | 50 | : []), |
| 51 | 51 | ]; |
| 52 | 52 | let rows = await linesOf(); |
| 53 | // The page's condition: the owner, not severed, and the parent has something. | |
| 54 | let behind = owner ? ((await upstreamState(app))?.behind ?? 0) : 0; | |
| 53 | 55 | if (!owner && !writer) return familyScreen(ctx, rows, "Chain"); |
| 54 | 56 | const self: Screen = { |
| 55 | 57 | title: "Chain", |
| 56 | 58 | keys: [ |
| 57 | 59 | ...(writer ? [binding(["c"], "check now")] : []), |
| 58 | 60 | ...(writer && proposing ? [binding(["p"], "propose")] : []), |
| 61 | ...(behind > 0 ? [binding(["t"], `take ${behind} new commit${behind === 1 ? "" : "s"}`)] : []), | |
| 59 | 62 | ...(owner ? [binding(["m"], "follow mode"), binding(["s"], "sever")] : []), |
| 60 | 63 | binding(["esc"], "close"), |
| 61 | 64 | ], |
| @@ -93,6 +96,13 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): | ||
| 93 | 96 | rows = await linesOf(); |
| 94 | 97 | ctx.toast(`Proposed to the parent forge as #${made.value.number}`, "success"); |
| 95 | 98 | }); |
| 99 | else if (e.key === "t" && owner && behind > 0) | |
| 100 | void takeForgeCode(app, user, repo).then(async (took) => { | |
| 101 | if (!took.ok) return ctx.toast(`Not taken: ${took.error}`, "danger"); | |
| 102 | const said = takeForgeWords(took.value); | |
| 103 | rows = await linesOf(); | |
| 104 | ctx.toast(said.message, said.tone ?? "success"); | |
| 105 | }); | |
| 96 | 106 | else if (e.key === "m" && owner) |
| 97 | 107 | void forgeLinkOf(app).then(async (link) => { |
| 98 | 108 | const next: FollowMode = link?.mode === "take-updates" ? "tell-me" : "take-updates"; |
src/web/pages/chain.ts +26 −2
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * sever. A severed forge says so with the date and nothing is deleted. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge } from "../../actions/follow.ts"; | |
| 8 | import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts"; | |
| 9 | 9 | import { forgeProposals, proposeToParentForge, type ForgeProposal } from "../../actions/upstream.ts"; |
| 10 | 10 | import { canAdmin, canWrite } from "../../core/repos.ts"; |
| 11 | 11 | import type { Ctx } from "../../http/context.ts"; |
| @@ -44,7 +44,7 @@ function modeCard(base: string, current: FollowMode | null): Html { | ||
| 44 | 44 | ); |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | const MODE_WORDS = { "tell-me": "tell me", "take-updates": "take updates (recorded; nothing applies them yet)" } as const; | |
| 47 | const MODE_WORDS = { "tell-me": "tell me", "take-updates": "take updates (as a pull you merge)" } as const; | |
| 48 | 48 | |
| 49 | 49 | function lineRow(l: ChainLine, upstream?: string): Html { |
| 50 | 50 | const where = l.host === "" ? "" : ` on ${l.host}`; |
| @@ -141,6 +141,13 @@ export async function chainPage(ctx: Ctx): Promise<Response> { | ||
| 141 | 141 | <div class="form-actions">${btn("Check the parent now", { type: "submit" })}</div> |
| 142 | 142 | </form>` |
| 143 | 143 | : null} |
| 144 | ${owner && view.severedAt === null && (state?.behind ?? 0) > 0 | |
| 145 | ? html`<form method="post" action="${base}/following/take" class="mt"> | |
| 146 | <div class="form-actions"> | |
| 147 | ${btn(`Take the ${state!.behind} new commit${state!.behind === 1 ? "" : "s"}`, { type: "submit", icon: "merge" })} | |
| 148 | </div> | |
| 149 | </form>` | |
| 150 | : null} | |
| 144 | 151 | ${owner && view.severedAt === null ? modeCard(base, view.lines[view.lines.length - 1]?.mode ?? null) : null} |
| 145 | 152 | ${proposing ? proposalCard(base, proposals, writer) : null} |
| 146 | 153 | ${view.severedAt !== null |
| @@ -204,6 +211,23 @@ export async function checkSubmit(ctx: Ctx): Promise<Response> { | ||
| 204 | 211 | return flashOrJson(ctx, to, state.error === "" ? { message: upstreamWords(state) } : { tone: "danger", message: upstreamWords(state) }); |
| 205 | 212 | } |
| 206 | 213 | |
| 214 | /** POST /:owner/:name/following/take: the parent's new code as one pull; the owner only. */ | |
| 215 | export async function takeSubmit(ctx: Ctx): Promise<Response> { | |
This action now exists on the page and in the API. It also needs a key on the terminal's chain screen ( |
||
| 216 | const repo = loadRepo(ctx); | |
| 217 | if (!repo) return notFoundPage(ctx); | |
| 218 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 219 | const form = await readForm(ctx, "rw"); | |
| 220 | if (form instanceof Response) return form; | |
| 221 | const to = `${repoBase(repo)}/following`; | |
| 222 | const took = await takeForgeCode(ctx.app, ctx.auth.user, repo); | |
| 223 | if (!took.ok) | |
| 224 | return flashOrJson(ctx, to, { | |
| 225 | tone: "danger", | |
| 226 | message: took.error === "not_following" ? "This repository follows nothing." : "Only this forge's owner can take updates.", | |
| 227 | }); | |
| 228 | return flashOrJson(ctx, to, takeForgeWords(took.value)); | |
| 229 | } | |
| 230 | ||
| 207 | 231 | /** POST /:owner/:name/following/mode {mode}; the owner only. */ |
| 208 | 232 | export async function modeSubmit(ctx: Ctx): Promise<Response> { |
| 209 | 233 | const repo = loadRepo(ctx); |
test/spawn-chain.test.ts +90 −2
| @@ -20,7 +20,10 @@ import { runGit } from "../src/git/env.ts"; | ||
| 20 | 20 | import { repoDir } from "../src/git/repos.ts"; |
| 21 | 21 | import { commitFiles } from "../src/git/write.ts"; |
| 22 | 22 | import { serve, type Servers } from "../src/http/server.ts"; |
| 23 | import { upstreamState } from "../src/spawn/upstream.ts"; | |
| 23 | import { chainScreen } from "../src/tui/screens/chain.ts"; | |
| 24 | import { pullForBranch } from "../src/core/pulls.ts"; | |
| 25 | import { TAKE_BRANCH } from "../src/spawn/take.ts"; | |
| 26 | import { checkUpstream, upstreamState } from "../src/spawn/upstream.ts"; | |
| 24 | 27 | import { chainLines, chainView, forgeLinkOf, severChain } from "../src/spawn/chain.ts"; |
| 25 | 28 | import { parseBeat, sendBeat, standing } from "../src/spawn/heartbeat.ts"; |
| 26 | 29 | import { planSpawn } from "../src/spawn/plan.ts"; |
| @@ -136,7 +139,7 @@ describe("the chain of forges", () => { | ||
| 136 | 139 | const page = (await (await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " "); |
| 137 | 140 | expect(page).toContain("alice/forge-code"); |
| 138 | 141 | expect(page).toContain("kid/forge"); |
| 139 | expect(page).toContain("take updates (recorded"); | |
| 142 | expect(page).toContain("take updates (as a pull you merge)"); | |
| 140 | 143 | expect(page).toContain("tell me · "); |
| 141 | 144 | expect(page).toContain("not checked yet"); |
| 142 | 145 | expect(page).toContain("1 of its own"); |
| @@ -208,6 +211,91 @@ describe("the chain of forges", () => { | ||
| 208 | 211 | expect(got.upstream).toBeNull(); |
| 209 | 212 | }); |
| 210 | 213 | |
| 214 | test("take: an admin's press opens one pull and never moves main; a reader is refused; nothing behind is up to date", async () => { | |
| 215 | const owner = getUserByName(grand.db, "alice")!; | |
| 216 | const bearer = (id: string) => ({ authorization: `Bearer ${createToken(grand.db, id, "take", "rw").token}` }); | |
| 217 | const take = (headers: Record<string, string>) => | |
| 218 | fetch(`${servers.url}/api/v1/repos/tot/forge/following/take`, { method: "POST", headers }); | |
| 219 | const dir = repoDir(grand.reposRoot, "tot", "forge"); | |
| 220 | const tip = async () => { | |
| 221 | const r = await runGit(["rev-parse", "refs/heads/main"], { cwd: dir }); | |
| 222 | return r.ok ? r.value.trim() : ""; | |
| 223 | }; | |
| 224 | const before = await tip(); | |
| 225 | ||
| 226 | const none = await take(bearer(owner.id)); | |
| 227 | expect(none.status).toBe(200); | |
| 228 | const nothing = (await none.json()) as any; | |
| 229 | expect(nothing.kind).toBe("current"); | |
| 230 | expect(nothing.message).toBe("Already up to date."); | |
| 231 | expect(pullForBranch(grand.db, getRepo(grand.db, "tot", "forge")!.id, TAKE_BRANCH)).toBeUndefined(); | |
| 232 | ||
| 233 | const reader = await createUser(grand.db, { origin: "seed", trusted: true, username: "takereader", email: "[email protected]" }); | |
| 234 | if (!reader.ok) throw new Error("reader"); | |
| 235 | expect([401, 403]).toContain((await take(bearer(reader.value.id))).status); | |
| 236 | expect((await take({})).status).toBe(401); | |
| 237 | ||
| 238 | // The parent moves; the grandchild looks (its remote is the child's repository on disk). | |
| 239 | const moved = await commitFiles(repoDir(child.reposRoot, "kid", "forge"), { | |
| 240 | branch: "main", | |
| 241 | message: "kid moves", | |
| 242 | files: [{ path: "NEWS.md", content: "news\n" }], | |
| 243 | author, | |
| 244 | }); | |
| 245 | if (!moved.ok) throw new Error("kid commit"); | |
| 246 | const state = await checkUpstream(grand, { remote: repoDir(child.reposRoot, "kid", "forge") }); | |
| 247 | expect(state?.behind).toBe(1); | |
| 248 | const page = (await (await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " "); | |
| 249 | expect(page).toContain("Take the 1 new commit"); | |
| 250 | expect(page).toContain('action="/tot/forge/following/take"'); | |
| 251 | ||
| 252 | expect([401, 403]).toContain((await take(bearer(reader.value.id))).status); | |
| 253 | expect(pullForBranch(grand.db, getRepo(grand.db, "tot", "forge")!.id, TAKE_BRANCH)).toBeUndefined(); | |
| 254 | const cross = await fetch(`${servers.url}/tot/forge/following/take`, { | |
| 255 | method: "POST", | |
| 256 | headers: { cookie, origin: "https://evil.test" }, | |
| 257 | body: new URLSearchParams({}), | |
| 258 | redirect: "manual", | |
| 259 | }); | |
| 260 | expect(cross.status).toBeGreaterThanOrEqual(400); | |
| 261 | expect(pullForBranch(grand.db, getRepo(grand.db, "tot", "forge")!.id, TAKE_BRANCH)).toBeUndefined(); | |
| 262 | ||
| 263 | const first = await fetch(`${servers.url}/tot/forge/following/take`, { | |
| 264 | method: "POST", | |
| 265 | headers: { cookie, origin: servers.url }, | |
| 266 | body: new URLSearchParams({}), | |
| 267 | redirect: "manual", | |
| 268 | }); | |
| 269 | expect(first.status).toBe(303); | |
| 270 | const pull = pullForBranch(grand.db, getRepo(grand.db, "tot", "forge")!.id, TAKE_BRANCH); | |
| 271 | expect(pull?.state).toBe("open"); | |
| 272 | expect(decodeURIComponent(first.headers.get("set-cookie") ?? "")).toContain(`in pull #${pull!.number}`); | |
| 273 | expect(await tip()).toBe(before); | |
| 274 | ||
| 275 | // Again, by the API, finds the same pull. | |
| 276 | const again = (await (await take(bearer(owner.id))).json()) as any; | |
| 277 | expect(again.kind).toBe("pull"); | |
| 278 | expect(again.number).toBe(pull!.number); | |
| 279 | expect(again.message).toBe(`1 commit from your parent is in pull #${pull!.number}.`); | |
| 280 | expect(await tip()).toBe(before); | |
| 281 | }); | |
| 282 | ||
| 283 | test("take: the terminal's t key is the owner's alone, shown when behind, and toasts the pull", async () => { | |
| 284 | const owner = getUserByName(grand.db, "alice")!; | |
| 285 | const reader = await createUser(grand.db, { origin: "seed", trusted: true, username: "ttyreader", email: "[email protected]" }); | |
| 286 | if (!reader.ok) throw new Error("reader"); | |
| 287 | const toasts: string[] = []; | |
| 288 | const ctx = { toast: (m: string) => toasts.push(m), pop() {}, push() {}, theme: {} } as any; | |
| 289 | const keysOf = (screen: any) => screen.keys.map((k: any) => k.keys?.[0] ?? k[0]); | |
| 290 | expect(keysOf(await chainScreen(grand, owner, ctx))).toContain("t"); | |
| 291 | expect(keysOf(await chainScreen(grand, reader.value, ctx))).not.toContain("t"); | |
| 292 | const screen = await chainScreen(grand, owner, ctx); | |
| 293 | screen.key({ key: "t" } as any); | |
| 294 | await Bun.sleep(500); | |
| 295 | const pull = pullForBranch(grand.db, getRepo(grand.db, "tot", "forge")!.id, TAKE_BRANCH); | |
| 296 | expect(toasts).toEqual([`1 commit from your parent is in pull #${pull!.number}.`]); | |
| 297 | }); | |
| 298 | ||
| 211 | 299 | test("sever needs the owner, a same-origin post and the word; it deletes nothing", async () => { |
| 212 | 300 | const url = `${servers.url}/tot/forge/following/sever`; |
| 213 | 301 | const body = (confirm: string) => new URLSearchParams({ confirm }); |
new above
offline
Nit:
Not taken: forbidden/not_followingshows a raw error code. The page has words for the same refusals ("Only this forge's owner can take updates." / "This repository follows nothing."). Consider sharing one refusal map, asFOLLOW_MODE_REFUSALSdoes for mode changes, so both doors say the same thing.