Public: anyone can read this; the crew can change it.

A fork's page says in one line how it follows its upstream and an admin switches it in one tap #10765

Merged opened this

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

Merged

mason/forge:mason/10748-a-fork-s-page-says-in-one-line-how-it-fo → main 4 commits fork 3 threads · 3 unresolved

  • Merged. mason/10748-a-fork-s-page-says-in-one-line-how-it-fo was merged into main as 90a5971.

Review

Sign in to review this change.
14 files changed +138 −33
docs/features/follow-mode.md added +11
@@ -0,0 +1,11 @@
1 # How a fork follows its upstream
2
3 A fork's page says it in one line: **Proposes updates from ann/yard** (you are told, and take updates when you choose) or **Follows ann/yard automatically** (new code is merged as it lands; a conflict goes to the crew). The line shows only on a fork, to anyone who can see the page.
4
5 ## Switching
6
7 - **Web:** an admin of the fork sees one button beside the line. It opens a confirm that says what changes ("without asking", "nothing is merged until you take it"); the POST is same-origin only, and the change is recorded as a `forge.follow_mode` event with the repository and the person.
8 - **MCP:** `follow_mode` with `repo` and `mode` (`tell-me` or `take-updates`) switches it for an admin and answers in the same words as the page. Anyone else is refused.
9 - **Terminal:** on a fork's screen the row shows the same line; `m` flips the mode and the toast says the same sentence.
10
11 The words live in `src/actions/follow_words.ts` and `followModeSaid` in `src/actions/follow.ts`; every door reads them from there.
src/actions/follow.ts +8 −1
@@ -14,7 +14,7 @@ import type { User } from "../core/users.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
1515 import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts";
1616 import { FAMILY_WORDS } from "./family_words.ts";
17 import { FOLLOWS_NOTHING } from "./follow_words.ts";
17 import { FOLLOWS_NOTHING, followModeWords } from "./follow_words.ts";
1818 import { forkFollowMode, setForkFollowMode } from "../core/fork_follow.ts";
1919 import { requireUser } from "./access.ts";
2020 import { takeFromParent, type Taken } from "../spawn/take.ts";
@@ -146,6 +146,13 @@ export async function setFollowMode(
146146 return ok(mode);
147147 }
148148
149 /** The sentence every door answers a mode change with. */
150 export function followModeSaid(parent: Repo | undefined, mode: FollowMode): string {
151 const upstream = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`;
152 const words = followModeWords(mode, upstream);
153 return `${words.line}. ${words.reason}`;
154 }
155
149156 /** What the crew did about a conflict, in a sentence; `danger` when nothing is in hand. */
150157 export function convergeWords(
151158 outcome: Converge,
src/actions/follow_words.ts +27
@@ -1,3 +1,5 @@
1 import type { FollowMode } from "../spawn/chain.ts";
2
13 /**
24 * Following's refusals in words (#5589): said by the chain page and the
35 * following page alike, so they are typed once and read from here.
@@ -23,3 +25,28 @@ export function proposeRefusalWords(
2325 if (typeof e === "string") return PROPOSE_NEEDS_WRITE;
2426 return `${parentName} did not take the proposal: ${e.reason}.`;
2527 }
outdated

Type the mode as FollowMode (from spawn/chain.ts) instead of repeating the literal union in two functions and in other. One definition keeps the modes from drifting.

28
29 /** How a fork follows its upstream, in one line and the reason beside it: the page and the MCP say these words. */
30 export function followModeWords(
31 mode: FollowMode,
32 upstream: string,
33 ): { readonly line: string; readonly reason: string; readonly other: FollowMode } {
34 return mode === "take-updates"
35 ? {
36 line: `Follows ${upstream} automatically`,
37 reason: `New code from ${upstream} is merged in as it lands; a conflict goes to the crew.`,
38 other: "tell-me",
39 }
40 : {
41 line: `Proposes updates from ${upstream}`,
42 reason: `You are told when ${upstream} has something new and take it when you choose.`,
43 other: "take-updates",
44 };
45 }
46
47 /** What switching to `mode` changes, for the confirm step. */
48 export function followSwitchPreview(mode: FollowMode, upstream: string): string {
49 return mode === "take-updates"
50 ? `From now on new code from ${upstream} is merged into this fork as it lands, without asking. When both sides changed the same files, nothing is merged and the crew is told to converge them. You can switch back at any time.`
51 : `From now on ${upstream} only proposes: you are told when it has something new and nothing is merged until you take it. You can switch back at any time.`;
52 }
src/http/mcp/tools/follow_mode.ts +3 −2
@@ -1,4 +1,5 @@
1 import { FOLLOW_MODE_REFUSALS, setFollowMode } from "../../../actions/follow.ts";
1 import { FOLLOW_MODE_REFUSALS, followModeSaid, setFollowMode } from "../../../actions/follow.ts";
2 import { forkParentFor } from "../../../core/repos.ts";
23 import { FOLLOW_MODES } from "../../../spawn/chain.ts";
34 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
45
@@ -20,7 +21,7 @@ export const tool: Tool = {
2021 const repo = repoArg(ctx, user, args);
2122 if (isAnswer(repo)) return repo;
2223 const set = await setFollowMode(ctx.app, user, repo, args["mode"]);
23 if (set.ok) return good({ says: `Now following in ${set.value} mode.`, mode: set.value });
24 if (set.ok) return good({ says: followModeSaid(forkParentFor(ctx.app.db, repo, user), set.value), mode: set.value });
2425 return bad(set.error, FOLLOW_MODE_REFUSALS[set.error].message);
2526 },
2627 };
src/http/server.ts +1
@@ -1449,6 +1449,7 @@ export function buildRouter(): Router<Handler> {
14491449 router.add("GET", "/:owner/:name/following", chainPage);
14501450 router.add("POST", "/:owner/:name/following/sever", severSubmit);
14511451 router.add("POST", "/:owner/:name/following/mode", modeSubmit);
1452 router.add("POST", "/:owner/:name/following/mode/:mode", modeSubmit);
14521453 router.add("POST", "/:owner/:name/following/take", takeSubmit);
14531454 router.add("POST", "/:owner/:name/following/propose", proposeParentSubmit);
14541455 router.add("POST", "/:owner/:name/following/check", checkSubmit);
src/tui/screens/chain.ts +2 −2
@@ -6,7 +6,7 @@
66 */
77
88 import type { App as Forge } from "../../app.ts";
9 import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts";
9 import { FOLLOW_MODE_REFUSALS, followModeSaid, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts";
1010 import { SEVER_CONFIRM } from "../../actions/sever_invite_words.ts";
1111 import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../../actions/upstream.ts";
1212 import { checkUpstream, upstreamState, upstreamWords } from "../../spawn/upstream.ts";
@@ -110,7 +110,7 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
110110 const set = await setFollowMode(app, user, repo, next);
111111 if (!set.ok) return ctx.toast(FOLLOW_MODE_REFUSALS[set.error].message, "danger");
112112 rows = await linesOf();
113 ctx.toast(`Now following in ${set.value} mode.`, "success");
113 ctx.toast(followModeSaid(undefined, set.value), "success");
114114 });
115115 else if (e.key === "s" && owner)
116116 ctx.push(
src/tui/screens/forks.ts +4 −3
@@ -18,7 +18,8 @@ import {
1818 type ForkRanking,
1919 type ForkRelation,
2020 } from "../../actions/forks.ts";
21 import { FOLLOW_MODE_REFUSALS, convergeWords, followModeOf, setFollowMode, takeOrTell, takeWords } from "../../actions/follow.ts";
21 import { followModeWords } from "../../actions/follow_words.ts";
22 import { FOLLOW_MODE_REFUSALS, convergeWords, followModeOf, followModeSaid, setFollowMode, takeOrTell, takeWords } from "../../actions/follow.ts";
2223 import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts";
2324 import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
2425 import type { Pull } from "../../core/pulls.ts";
@@ -100,7 +101,7 @@ export function followingRows(
100101 {
101102 repo: parent,
102103 name: `follows ${name}`,
103 words: mode === undefined ? words : `${words} · ${mode === "take-updates" ? "takes updates" : "tells you"}`,
104 words: mode === undefined ? words : `${words} · ${followModeWords(mode, name).line}`,
104105 heading: "",
105106 },
106107 ...(converge === undefined
@@ -304,7 +305,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
304305 const set = await setFollowMode(f.app, f.user, repo, next);
305306 if (!set.ok) return ctx.toast(FOLLOW_MODE_REFUSALS[set.error].message, "danger");
306307 await read();
307 ctx.toast(`Now following in ${set.value} mode.`, "success");
308 ctx.toast(followModeSaid(relation?.parent, set.value), "success");
308309 });
309310 return true;
310311 }
src/web/pages/chain.ts +5 −5
@@ -5,11 +5,11 @@
55 * sever. A severed forge says so with the date and nothing is deleted.
66 */
77
8 import { FOLLOW_MODE_REFUSALS, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts";
8 import { FOLLOW_MODE_REFUSALS, followModeSaid, setFollowMode, severForge, takeForgeCode, takeForgeWords } from "../../actions/follow.ts";
99 import { FOLLOWS_NOTHING, PROPOSE_NEEDS_WRITE } from "../../actions/follow_words.ts";
1010 import { forgeProposals, proposeToParentForge, type ForgeProposal } from "../../actions/upstream.ts";
1111 import { SEVER_CONFIRM } from "../../actions/sever_invite_words.ts";
12 import { canAdmin, canWrite } from "../../core/repos.ts";
12 import { canAdmin, canWrite, forkParentFor } from "../../core/repos.ts";
1313 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1414 import type { Ctx } from "../../http/context.ts";
1515 import { chainView, FOLLOW_MODES, forgeLinkOf, type ChainLine, type FollowMode } from "../../spawn/chain.ts";
@@ -228,7 +228,7 @@ export async function takeSubmit(ctx: Ctx): Promise<Response> {
228228 return flashOrJson(ctx, to, takeForgeWords(took.value));
229229 }
230230
231 /** POST /:owner/:name/following/mode {mode}; the owner only. */
231 /** POST /:owner/:name/following/mode {mode} or /following/mode/:mode (what the confirm step posts); the owner only. */
232232 export async function modeSubmit(ctx: Ctx): Promise<Response> {
233233 const repo = loadRepo(ctx);
234234 if (!repo) return notFoundPage(ctx);
@@ -236,9 +236,9 @@ export async function modeSubmit(ctx: Ctx): Promise<Response> {
236236 const form = await readForm(ctx, "rw");
237237 if (form instanceof Response) return form;
238238 const to = `${repoBase(repo)}/following`;
239 const set = await setFollowMode(ctx.app, ctx.auth.user, repo, field(form, "mode"));
239 const set = await setFollowMode(ctx.app, ctx.auth.user, repo, ctx.params["mode"] ?? field(form, "mode"));
240240 if (!set.ok) return flashOrJson(ctx, to, { tone: "danger", message: FOLLOW_MODE_REFUSALS[set.error].message });
241 return flashOrJson(ctx, to, { message: `Now following in ${set.value} mode.` });
241 return flashOrJson(ctx, to, { message: followModeSaid(forkParentFor(ctx.app.db, repo, ctx.auth.user), set.value) });
242242 }
243243
244244 /** POST /:owner/:name/following/propose: push to the parent forge and open the pull there; a writer only. */
src/web/pages/following.ts +13 −3
@@ -37,7 +37,7 @@ import {
3737 type ParentReleases,
3838 } from "../../core/parent_releases.ts";
3939 import { running } from "../../kernel/version.ts";
40 import { modeForm } from "./chain.ts";
40 import { followModeWords } from "../../actions/follow_words.ts";
4141 import type { FollowMode } from "../../spawn/chain.ts";
4242 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
4343 import { convergeWords, followModeOf, takeOrTell, takeWords } from "../../actions/follow.ts";
@@ -371,8 +371,18 @@ export function followingCard(
371371 if (parent.detached === null && relation.action !== "none")
372372 prose.push(html`<p class="muted small">${relation.words}</p>`);
373373 if (foot !== null) prose.push(html`<p class="muted small">${foot}</p>`);
374 if (canAdmin(ctx.app.db, repo, ctx.auth.user) && writer)
375 prose.push(modeForm(base, following.mode, "How this fork follows its parent"));
374 const words = followModeWords(following.mode, parentName);
375 prose.push(
376 html`<p data-follow-mode="${following.mode}">
377 <strong>${words.line}</strong>.
378 ${canAdmin(ctx.app.db, repo, ctx.auth.user) && writer
379 ? btn(following.mode === "take-updates" ? "Only propose updates" : "Follow automatically", {
380 href: `/-/confirm?to=${encodeURIComponent(`${base}/following/mode/${words.other}`)}`,
381 size: "sm",
382 })
383 : null}
384 </p>`,
outdated

Principle 1 (nothing explains itself twice): the card already prints relation.words and a foot line just above, and now adds line plus reason. Fold them so the card says the mode once. Also the line renders for any viewer who can see the card, so check that it appears only for forks; the test covers the reader but not a non-fork repo.

385 );
376386 if (followers > 0)
377387 rows.push(
378388 row({
src/web/pages/search.ts +14 −1
@@ -17,7 +17,9 @@ import { envsFor } from "../../apps/runtime.ts";
1717 import type { AppRecord } from "../../core/apps.ts";
1818 import { openLabelCounts } from "../../core/issues.ts";
1919 import { getPull } from "../../core/pulls.ts";
20 import { canAdmin, canWrite, enterableApps, getRepo } from "../../core/repos.ts";
20 import { canAdmin, canWrite, enterableApps, forkParentFor, getRepo } from "../../core/repos.ts";
21 import { followModeWords, followSwitchPreview } from "../../actions/follow_words.ts";
22 import type { FollowMode } from "../../spawn/chain.ts";
2123 import { getMirrorTarget } from "../../core/migrate_mirror.ts";
2224 import {
2325 cleanQuery,
@@ -901,6 +903,17 @@ const DESTRUCTIVE: {
901903 };
902904 },
903905 },
906 {
907 pattern: /^\/([^/]+)\/([^/]+)\/following\/mode\/(tell-me|take-updates)$/,
908 describe: (m, ctx) => {
909 const mode = m[3] as FollowMode;
910 const repo = getRepo(ctx.app.db, m[1]!, m[2]!);
911 const parent = repo && canWrite(ctx.app.db, repo, ctx.auth.user) ? forkParentFor(ctx.app.db, repo, ctx.auth.user) : undefined;
912 const upstream = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`;
913 const words = followModeWords(mode, upstream);
914 return { verb: "Switch", title: `${words.line}?`, detail: followSwitchPreview(mode, upstream), phrase: "" };
915 },
916 },
outdated

Dead branch. The pattern only matches tell-me|take-updates, so isFollowMode(mode) is always true here; the guard exists only to satisfy the type. Narrow the type with a cast or a const map and drop the unreachable 'Pick a mode' answer. Also the preview shows 'its parent' to a non-writer, which is fine, but add a test for it.

904917 {
905918 pattern: /^\/admin\/invites\/([^/]+)\/revoke$/,
906919 describe: (m) => ({
test/fork-follow-mode.test.ts +2 −2
@@ -126,10 +126,10 @@ describe("the terminal", () => {
126126 test("the owner sees the mode and flips it with m", async () => {
127127 must(await setFollowMode(app, users["bea"]!, fork, "tell-me"));
128128 const before = await open("bea", "");
129 expect(before).toContain("tells you");
129 expect(before).toContain("Proposes updates from");
130130 expect(before).toContain("follow mode");
131131 const after = await open("bea", "m");
132 expect(after).toContain("takes updates");
132 expect(after).toContain("Follows ann/yard automatically");
133133 expect(forkFollowMode(app.db, fork.id)).toBe("take-updates");
134134 });
135135
test/fork-tree-web.test.ts +40 −12
@@ -19,6 +19,8 @@ import { getRepo, type Repo } from "../src/core/repos.ts";
1919 import { createUser, type User } from "../src/core/users.ts";
2020 import { serve, type Servers } from "../src/http/server.ts";
2121 import { forkFollowMode } from "../src/core/fork_follow.ts";
22 import { listEvents } from "../src/core/events.ts";
23 import { followModeSaid } from "../src/actions/follow.ts";
2224 import { quietLogs } from "./helpers/globals.ts";
2325
2426 quietLogs();
@@ -122,37 +124,63 @@ describe("the fork tree, on one forge", () => {
122124 expect(tree.indexOf("you are here", mark + 1)).toBe(-1);
123125 });
124126
125 test("a plain fork's page offers the modes; posting one changes it, and the page shows it", async () => {
127 test("a plain fork's page says how it follows in one line; the admin switches it after a confirm, a stranger and a cross-origin post change nothing", async () => {
126128 const cookie = await signIn("bea");
127129 const before = await (await get("/bea/yard", cookie)).text();
128 expect(before).toContain('name="mode" value="tell-me"');
129 expect(before).toContain('name="mode" value="take-updates"');
130 expect(before).toMatch(/value="tell-me"\s+checked/);
130 expect(before).toContain("Proposes updates from ann/yard");
131 expect(before).not.toContain("automatically</strong>");
132 expect(before).toContain("/-/confirm?to=%2Fbea%2Fyard%2Ffollowing%2Fmode%2Ftake-updates");
131133
132 const cross = await post("/bea/yard/following/mode", cookie, { mode: "take-updates" }, "https://evil.test");
134 const confirm = await (await get("/-/confirm?to=%2Fbea%2Fyard%2Ffollowing%2Fmode%2Ftake-updates", cookie)).text();
135 expect(confirm).toContain("Follows ann/yard automatically?");
136 expect(confirm).toContain("the crew is told to converge");
137 expect(confirm).toContain('action="/bea/yard/following/mode/take-updates"');
138
139 const cross = await post("/bea/yard/following/mode/take-updates", cookie, {}, "https://evil.test");
133140 expect(cross.status).toBe(403);
141 const stranger = await post("/bea/yard/following/mode/take-updates", await signIn("cal"), {});
142 expect(stranger.status).toBe(303);
134143 expect(forkFollowMode(app.db, middle.id)).toBe("tell-me");
135144
136 const changed = await post("/bea/yard/following/mode", cookie, { mode: "take-updates" });
145 const audits = () => listEvents(app.db, { kind: "forge.follow_mode" });
146 const audited = audits().length;
147 const changed = await post("/bea/yard/following/mode/take-updates", cookie, {});
137148 expect(changed.status).toBe(303);
138149 expect(changed.headers.get("location")).toBe("/bea/yard/following");
139150 expect(forkFollowMode(app.db, middle.id)).toBe("take-updates");
151 expect(audits().length).toBe(audited + 1);
152 const receipt = audits()[0]!;
153 expect(receipt.repoId).toBe(middle.id);
154 expect(receipt.actorId).toBe(bea.id);
155 expect(receipt.payload).toMatchObject({ repo: "bea/yard", mode: "take-updates" });
140156
141157 const after = await (await get("/bea/yard", cookie)).text();
142 expect(after).toMatch(/value="take-updates"\s+checked/);
143 expect(after).not.toMatch(/value="tell-me"\s+checked/);
158 expect(after).toContain("Follows ann/yard automatically");
159 expect(after).toContain('data-follow-mode="take-updates"');
160 expect(after).toContain("Only propose updates");
144161
145 // A reader without write access is not offered the form.
162 // A reader sees the line but no switch.
146163 const reader = await (await get("/bea/yard", await signIn("cal"))).text();
147 expect(reader).not.toContain('name="mode"');
148 expect(await (await fetch(`${servers.url}/bea/yard`)).text()).not.toContain('name="mode"');
149 expect(after).toContain("How this fork follows its parent");
164 expect(reader).toContain("Follows ann/yard automatically");
165 expect(reader).not.toContain("following%2Fmode");
150166
151167 const bad = await post("/bea/yard/following/mode", cookie, { mode: "nope" });
152168 expect(bad.status).toBe(303);
153169 expect(forkFollowMode(app.db, middle.id)).toBe("take-updates");
154170 });
155171
172 test("the confirm for tell-me says it only proposes; a non-writer sees 'its parent'; a root repository has no line", async () => {
173 const confirm = await (await get("/-/confirm?to=%2Fbea%2Fyard%2Ffollowing%2Fmode%2Ftell-me", await signIn("bea"))).text();
174 expect(confirm).toContain("Proposes updates from ann/yard?");
175 expect(confirm).toContain("nothing is merged until you take it");
176 const stranger = await (await get("/-/confirm?to=%2Fbea%2Fyard%2Ffollowing%2Fmode%2Ftell-me", await signIn("cal"))).text();
177 expect(stranger).toContain("Proposes updates from its parent?");
178 expect(stranger).not.toContain("from ann/yard");
179 const root = await (await get("/ann/yard", await signIn("ann"))).text();
180 expect(root).not.toContain("data-follow-mode");
181 expect(followModeSaid(undefined, "tell-me")).toContain("Proposes updates from its parent.");
182 });
183
156184 test("one POST from the offer page opens exactly one pull on the parent", async () => {
157185 const cookie = await signIn("bea");
158186 const fork = getRepo(app.db, "bea", "yard")!;
test/spawn-chain-doors.test.ts +1 −1
@@ -185,7 +185,7 @@ describe("the chain at the MCP and terminal doors", () => {
185185 await owner.openChain();
186186 expect(owner.text()).toContain("follow mode");
187187 await owner.press("m");
188 await settleUntil(owner.app, `Now following in ${next} mode`);
188 await settleUntil(owner.app, next === "tell-me" ? "Proposes updates from its parent." : "Follows its parent automatically.");
189189 expect((await forgeLinkOf(child))?.mode).toBe(next);
190190 expect(events()).toBe(before + 1);
191191 owner.app.stop();
test/spawn-chain-mode.test.ts +7 −1
@@ -143,8 +143,14 @@ describe("choosing how a forge follows its parent", () => {
143143 expect(((await res.json()) as any).mode).toBe("tell-me");
144144 const viaTool = await tool(ownerTok, { repo: "kid/forge", mode: "take-updates" });
145145 expect(viaTool.error).toBe(false);
146 expect(viaTool.text).toContain("automatically");
147 const told = await tool(ownerTok, { repo: "kid/forge", mode: "tell-me" });
148 expect(told.error).toBe(false);
149 expect(told.text).toContain("Proposes updates from");
150 expect((await tool(readerTok, { repo: "kid/forge", mode: "tell-me" })).error).toBe(true);
151 expect((await tool(ownerTok, { repo: "kid/forge", mode: "take-updates" })).error).toBe(false);
146152 expect((await forgeLinkOf(child))?.mode).toBe("take-updates");
147 expect(listEvents(child.db, { kind: "forge.follow_mode" })).toHaveLength(3);
153 expect(listEvents(child.db, { kind: "forge.follow_mode" })).toHaveLength(5);
148154 });
149155
150156 test("a severed forge refuses with 409 and keeps its record", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs