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

Merged opened this

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

Merged

scout/forge:scout/2651-the-chain-page-offers-take-the-n-new-com → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/2651-the-chain-page-offers-take-the-n-new-com was merged into main as 0517a4a.

Review

Sign in to review this change.
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
194194 is appended. A non-admin is refused (403), a value outside the two modes is
195195 422, and a severed forge is 409; each leaves `parent.json` untouched.
196196
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
197203 The page and `GET …/following/proposals` show each proposal as open, merged or
198204 closed, read from the parent (the token may read back only pulls it opened) and
199205 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";
1212 import { ARCHIVED_WHY, canAdmin, canWrite, forkParentFor, type Repo } from "../core/repos.ts";
1313 import type { User } from "../core/users.ts";
1414 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";
1616 import { requireUser } from "./access.ts";
17 import { takeFromParent, type Taken } from "../spawn/take.ts";
1718 import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts";
1819
1920 export type TakeOrTell = Result<ForkSync & { merged: boolean }, TakeUpdatesError | Conflict>;
@@ -62,6 +63,39 @@ export async function severForge(
6263 return ok(cut.value);
6364 }
6465
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
6599 export type FollowModeError = SeverError | "invalid_mode" | "severed";
66100
67101 /** 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";
6363 import { reissueDoor, spawnDoor } from "../spawn/door.ts";
6464 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
6565 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";
6767 import { relationOf } from "../actions/forks.ts";
6868 import { chainOfRepo, forgeLinkOf } from "../spawn/chain.ts";
6969 import { noteRead, telemetryReader, type Reader } from "../actions/telemetry.ts";
@@ -2336,7 +2336,14 @@ export function apiRoutes(router: Router<Handler>): void {
23362336 if (denied) return denied;
23372337 const repo = loadRepo(ctx);
23382338 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) });
23402347 },
23412348 );
23422349
src/http/server.ts +2 −1
@@ -128,7 +128,7 @@ import {
128128 signinPasskey,
129129 } from "../web/pages/passkeys.ts";
130130 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";
132132 import { followingSubmit } from "../web/pages/following.ts";
133133 import { reconnectSubmit } from "../web/pages/frozen.ts";
134134 import { activityAtom } from "../web/feeds.ts";
@@ -1273,6 +1273,7 @@ export function buildRouter(): Router<Handler> {
12731273 router.add("GET", "/:owner/:name/following", chainPage);
12741274 router.add("POST", "/:owner/:name/following/sever", severSubmit);
12751275 router.add("POST", "/:owner/:name/following/mode", modeSubmit);
1276 router.add("POST", "/:owner/:name/following/take", takeSubmit);
12761277 router.add("POST", "/:owner/:name/following/propose", proposeParentSubmit);
12771278 router.add("POST", "/:owner/:name/following/check", checkSubmit);
12781279 // 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 @@
22 * The chain of forges this forge's code descends from, in a card. The gate is
33 * the page's: only someone who can read `<org>/forge` sees it, and only that
44 * 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.
66 */
77
88 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";
1010 import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../../actions/upstream.ts";
1111 import { checkUpstream, upstreamState, upstreamWords } from "../../spawn/upstream.ts";
1212 import { parentCredential } from "../../spawn/parent_credential.ts";
@@ -50,12 +50,15 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
5050 : []),
5151 ];
5252 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;
5355 if (!owner && !writer) return familyScreen(ctx, rows, "Chain");
5456 const self: Screen = {
5557 title: "Chain",
5658 keys: [
5759 ...(writer ? [binding(["c"], "check now")] : []),
5860 ...(writer && proposing ? [binding(["p"], "propose")] : []),
61 ...(behind > 0 ? [binding(["t"], `take ${behind} new commit${behind === 1 ? "" : "s"}`)] : []),
5962 ...(owner ? [binding(["m"], "follow mode"), binding(["s"], "sever")] : []),
6063 binding(["esc"], "close"),
6164 ],
@@ -93,6 +96,13 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
9396 rows = await linesOf();
9497 ctx.toast(`Proposed to the parent forge as #${made.value.number}`, "success");
9598 });
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");

Nit: Not taken: forbidden / not_following shows 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, as FOLLOW_MODE_REFUSALS does for mode changes, so both doors say the same thing.

102 const said = takeForgeWords(took.value);
103 rows = await linesOf();
104 ctx.toast(said.message, said.tone ?? "success");
105 });
96106 else if (e.key === "m" && owner)
97107 void forgeLinkOf(app).then(async (link) => {
98108 const next: FollowMode = link?.mode === "take-updates" ? "tell-me" : "take-updates";
src/web/pages/chain.ts +26 −2
@@ -5,7 +5,7 @@
55 * sever. A severed forge says so with the date and nothing is deleted.
66 */
77
8 import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge } from "../../actions/follow.ts";
8 import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts";
99 import { forgeProposals, proposeToParentForge, type ForgeProposal } from "../../actions/upstream.ts";
1010 import { canAdmin, canWrite } from "../../core/repos.ts";
1111 import type { Ctx } from "../../http/context.ts";
@@ -44,7 +44,7 @@ function modeCard(base: string, current: FollowMode | null): Html {
4444 );
4545 }
4646
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;
4848
4949 function lineRow(l: ChainLine, upstream?: string): Html {
5050 const where = l.host === "" ? "" : ` on ${l.host}`;
@@ -141,6 +141,13 @@ export async function chainPage(ctx: Ctx): Promise<Response> {
141141 <div class="form-actions">${btn("Check the parent now", { type: "submit" })}</div>
142142 </form>`
143143 : 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}
144151 ${owner && view.severedAt === null ? modeCard(base, view.lines[view.lines.length - 1]?.mode ?? null) : null}
145152 ${proposing ? proposalCard(base, proposals, writer) : null}
146153 ${view.severedAt !== null
@@ -204,6 +211,23 @@ export async function checkSubmit(ctx: Ctx): Promise<Response> {
204211 return flashOrJson(ctx, to, state.error === "" ? { message: upstreamWords(state) } : { tone: "danger", message: upstreamWords(state) });
205212 }
206213
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 (src/tui/screens/chain.ts), shown only for the owner and only when the forge is behind, with a toast that uses takeForgeWords.

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
207231 /** POST /:owner/:name/following/mode {mode}; the owner only. */
208232 export async function modeSubmit(ctx: Ctx): Promise<Response> {
209233 const repo = loadRepo(ctx);
test/spawn-chain.test.ts +90 −2
@@ -20,7 +20,10 @@ import { runGit } from "../src/git/env.ts";
2020 import { repoDir } from "../src/git/repos.ts";
2121 import { commitFiles } from "../src/git/write.ts";
2222 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";
2427 import { chainLines, chainView, forgeLinkOf, severChain } from "../src/spawn/chain.ts";
2528 import { parseBeat, sendBeat, standing } from "../src/spawn/heartbeat.ts";
2629 import { planSpawn } from "../src/spawn/plan.ts";
@@ -136,7 +139,7 @@ describe("the chain of forges", () => {
136139 const page = (await (await fetch(`${servers.url}/tot/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " ");
137140 expect(page).toContain("alice/forge-code");
138141 expect(page).toContain("kid/forge");
139 expect(page).toContain("take updates (recorded");
142 expect(page).toContain("take updates (as a pull you merge)");
140143 expect(page).toContain("tell me · ");
141144 expect(page).toContain("not checked yet");
142145 expect(page).toContain("1 of its own");
@@ -208,6 +211,91 @@ describe("the chain of forges", () => {
208211 expect(got.upstream).toBeNull();
209212 });
210213
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
211299 test("sever needs the owner, a same-origin post and the word; it deletes nothing", async () => {
212300 const url = `${servers.url}/tot/forge/following/sever`;
213301 const body = (confirm: string) => new URLSearchParams({ confirm });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs