Public: anyone can read this; the crew can change it.
The fork tree, browsable, and each fork chooses how it follows #10990
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/608-the-fork-tree-browsable-and-each-fork-ch → main 6 commits fork 1 thread · 1 unresolved
21 files
changed +271
−75
docs/MCP.md +1 −1
| @@ -108,7 +108,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 108 | 108 | | `flag_winner` | — | An A/B flag winner becomes default | 206 | |
| 109 | 109 | | `flags` | — | Declared flags: why, default, owner issue, live value | 171 | |
| 110 | 110 | | `flow` | `bugs`, `drift` | Pipeline (admin); `q`: `/fleet` rows, `action` plans an op, `apply` does it; `kind:bugs`; `repo`: why stuck | 473 | |
| 111 | | `follow_mode` | — | How a spawned forge follows its parent: `tell-me` or `take-updates` | 265 | | |
| 111 | | `follow_mode` | — | How a fork follows its parent: take updates, tell me, or don't follow | 281 | | |
| 112 | 112 | | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 | |
| 113 | 113 | | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 | |
| 114 | 114 | | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 | |
docs/features/follow-mode.md +20 −4
| @@ -1,11 +1,27 @@ | ||
| 1 | 1 | # How a fork follows its upstream |
| 2 | 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. | |
| 3 | A fork follows its upstream in one of three modes, said in one line on its page: | |
| 4 | ||
| 5 | - **Follows ann/yard automatically** (`take-updates`): new code is merged as it lands; a conflict goes to the crew. | |
| 6 | - **Proposes updates from ann/yard** (`tell-me`, the default): a fork with nothing of its own still moves along; one with work of its own is told, and takes updates when its owner chooses. | |
| 7 | - **Does not follow ann/yard** (`dont-follow`, #608): nothing is fast-forwarded, merged or announced, and the crew is never asked to converge. The fork can still offer its changes up. | |
| 8 | ||
| 9 | The line shows only on a fork, to anyone who can see the page. | |
| 10 | ||
| 11 | ## Where | |
| 12 | ||
| 13 | The fork's repository page and its forks page (`/:owner/:name/forks`) carry the same Following card. On the forks page the card shows only to the fork's admin, above the family tree. The tree runs from the oldest ancestor the viewer may read, with each fork's ahead and behind counts against its own parent. When the fork is ahead, the card's **Offer this change** opens one pull on the parent (`proposeUpstream`). | |
| 4 | 14 | |
| 5 | 15 | ## Switching |
| 6 | 16 | |
| 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. | |
| 17 | - **Web:** an admin of the fork sees a button for each of the other two modes beside the line. Each opens a confirm that says what changes ("without asking", "nothing is merged until you take it", "the crew is never asked to converge"). The POST is same-origin only, and the change is recorded as a `forge.follow_mode` event with the repository and the person. | |
| 18 | - **MCP:** `follow_mode` with `repo` and `mode` (`take-updates`, `tell-me` or `dont-follow`) switches it for an admin and answers in the same words as the page. Anyone else is refused. `following` returns the current `mode`, and `fork_tree` names each node's `follow`. | |
| 19 | - **Terminal:** on a fork's screen the row shows the same line; `m` steps to the next mode and the toast says the same sentence. | |
| 20 | ||
| 21 | A spawned forge's chain (its code repository following its parent forge) takes only `tell-me` or `take-updates`. To follow nothing, it severs. | |
| 22 | ||
| 23 | ## What honours it | |
| 24 | ||
| 25 | `follow` in `src/core/following.ts` skips a `dont-follow` fork when its parent's default branch moves, and `followDivergence` in `src/crew/converge.ts` never converges one. | |
| 10 | 26 | |
| 11 | 27 | 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 +14 −8
| @@ -12,10 +12,10 @@ 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, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain, type FollowMode } from "../spawn/chain.ts"; | |
| 15 | import { chainOfRepo, forgeLinkOf, FOLLOW_MODES, isFollowMode, setChainMode, severChain } from "../spawn/chain.ts"; | |
| 16 | 16 | import { FAMILY_WORDS } from "./family_words.ts"; |
| 17 | 17 | import { FOLLOWS_NOTHING, followModeWords } from "./follow_words.ts"; |
| 18 | import { forkFollowMode, setForkFollowMode } from "../core/fork_follow.ts"; | |
| 18 | import { FORK_FOLLOW_MODES, forkFollowMode, isForkFollowMode, setForkFollowMode, type ForkFollowMode } from "../core/fork_follow.ts"; | |
| 19 | 19 | import { requireUser } from "./access.ts"; |
| 20 | 20 | import { takeFromParent, type Taken } from "../spawn/take.ts"; |
| 21 | 21 | import { takeUpdates, type ForkSync, type TakeUpdatesError } from "./forks.ts"; |
| @@ -100,7 +100,7 @@ export function takeForgeWords(r: TakeForgeResult): { tone?: "danger"; message: | ||
| 100 | 100 | } |
| 101 | 101 | |
| 102 | 102 | /** How a repository follows its parent now: the forge chain's record when it carries one, else the fork's own choice. */ |
| 103 | export async function followModeOf(app: App, repo: Repo): Promise<FollowMode> { | |
| 103 | export async function followModeOf(app: App, repo: Repo): Promise<ForkFollowMode> { | |
| 104 | 104 | const link = await forgeLinkOf(app); |
| 105 | 105 | return link !== undefined && link.repo === `${repo.ownerName}/${repo.name}` |
| 106 | 106 | ? link.mode |
| @@ -114,24 +114,30 @@ export const FOLLOW_MODE_REFUSALS: Record<FollowModeError, { status: number; mes | ||
| 114 | 114 | unauthorized: { status: 401, message: "sign in to change how this forge follows its parent" }, |
| 115 | 115 | forbidden: { status: 403, message: "only this forge's owner can change how it follows" }, |
| 116 | 116 | not_following: { status: 404, message: FAMILY_WORDS.not_following }, |
| 117 | invalid_mode: { status: 422, message: `mode is one of: ${FOLLOW_MODES.join(", ")}` }, | |
| 117 | invalid_mode: { status: 422, message: `mode is one of: ${FORK_FOLLOW_MODES.join(", ")}; a spawned forge takes ${FOLLOW_MODES.join(" or ")} and severs instead of not following` }, | |
| 118 | 118 | severed: { status: 409, message: "this forge is severed and follows nothing; there is no mode to change" }, |
| 119 | 119 | }; |
| 120 | 120 | |
| 121 | /** Choose how a forge follows its parent: the repository's admin only; the record changes atomically. */ | |
| 121 | /** | |
| 122 | * Choose how a fork follows its parent: the repository's admin only; the | |
| 123 | * record changes atomically. A plain fork takes any of `FORK_FOLLOW_MODES`; | |
| 124 | * a spawned forge's chain takes `tell-me` or `take-updates` and severs to | |
| 125 | * follow nothing. | |
| 126 | */ | |
| 122 | 127 | export async function setFollowMode( |
| 123 | 128 | app: App, |
| 124 | 129 | actor: User | undefined, |
| 125 | 130 | repo: Repo, |
| 126 | 131 | mode: unknown, |
| 127 | ): Promise<Result<FollowMode, FollowModeError>> { | |
| 132 | ): Promise<Result<ForkFollowMode, FollowModeError>> { | |
| 128 | 133 | const who = requireUser(actor); |
| 129 | 134 | if (!who.ok) return err("unauthorized"); |
| 130 | 135 | const carries = (await chainOfRepo(app, repo)) !== undefined; |
| 131 | 136 | if (!carries && repo.forkParentId === null) return err("not_following"); |
| 132 | 137 | if (!canAdmin(app.db, repo, who.value) || !canWrite(app.db, repo, who.value)) return err("forbidden"); |
| 133 | if (!isFollowMode(mode)) return err("invalid_mode"); | |
| 138 | if (!isForkFollowMode(mode)) return err("invalid_mode"); | |
| 134 | 139 | if (carries) { |
| 140 | if (!isFollowMode(mode)) return err("invalid_mode"); | |
| 135 | 141 | const set = await setChainMode(app, mode); |
| 136 | 142 | if (!set.ok) return err(set.error); |
| 137 | 143 | } else { |
| @@ -147,7 +153,7 @@ export async function setFollowMode( | ||
| 147 | 153 | } |
| 148 | 154 | |
| 149 | 155 | /** The sentence every door answers a mode change with. */ |
| 150 | export function followModeSaid(parent: Repo | undefined, mode: FollowMode): string { | |
| 156 | export function followModeSaid(parent: Repo | undefined, mode: ForkFollowMode): string { | |
| 151 | 157 | const upstream = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`; |
| 152 | 158 | const words = followModeWords(mode, upstream); |
| 153 | 159 | return `${words.line}. ${words.reason}`; |
src/actions/follow_words.ts +34 −18
| @@ -1,4 +1,4 @@ | ||
| 1 | import type { FollowMode } from "../spawn/chain.ts"; | |
| 1 | import { FORK_FOLLOW_MODES, type ForkFollowMode } from "../core/fork_follow.ts"; | |
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | 4 | * Following's refusals in words (#5589): said by the chain page and the |
| @@ -26,27 +26,43 @@ export function proposeRefusalWords( | ||
| 26 | 26 | return `${parentName} did not take the proposal: ${e.reason}.`; |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | /** The words of a button that switches a fork to `mode`. */ | |
| 30 | export const FOLLOW_SWITCH_LABELS: Record<ForkFollowMode, string> = { | |
| 31 | "take-updates": "Follow automatically", | |
| 32 | "tell-me": "Only propose updates", | |
| 33 | "dont-follow": "Stop following", | |
| 34 | }; | |
| 35 | ||
| 29 | 36 | /** How a fork follows its upstream, in one line and the reason beside it: the page and the MCP say these words. */ |
| 30 | 37 | export function followModeWords( |
| 31 | mode: FollowMode, | |
| 38 | mode: ForkFollowMode, | |
| 32 | 39 | 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 | }; | |
| 40 | ): { readonly line: string; readonly reason: string; readonly others: readonly ForkFollowMode[] } { | |
| 41 | const others = FORK_FOLLOW_MODES.filter((m) => m !== mode); | |
| 42 | if (mode === "take-updates") | |
| 43 | return { | |
| 44 | line: `Follows ${upstream} automatically`, | |
| 45 | reason: `New code from ${upstream} is merged in as it lands; a conflict goes to the crew.`, | |
| 46 | others, | |
| 47 | }; | |
| 48 | if (mode === "dont-follow") | |
| 49 | return { | |
| 50 | line: `Does not follow ${upstream}`, | |
| 51 | reason: `Nothing from ${upstream} is merged or announced here; it can still offer its changes up.`, | |
| 52 | others, | |
| 53 | }; | |
| 54 | return { | |
| 55 | line: `Proposes updates from ${upstream}`, | |
| 56 | reason: `You are told when ${upstream} has something new and take it when you choose.`, | |
| 57 | others, | |
| 58 | }; | |
| 45 | 59 | } |
| 46 | 60 | |
| 47 | 61 | /** 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.`; | |
| 62 | export function followSwitchPreview(mode: ForkFollowMode, upstream: string): string { | |
| 63 | if (mode === "take-updates") | |
| 64 | return `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.`; | |
| 65 | if (mode === "dont-follow") | |
| 66 | return `From now on this fork goes its own way: nothing from ${upstream} is merged or announced here, and the crew is never asked to converge. You can still offer your changes up, and follow again at any time.`; | |
| 67 | return `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 | 68 | } |
src/actions/fork_tree.ts +10 −3
| @@ -24,8 +24,11 @@ export const MAX_DEPTH = 8; | ||
| 24 | 24 | /** The most nodes any call returns; past it `truncated` is true. A page of a tree, not a crawl. */ |
| 25 | 25 | export const MAX_NODES = 200; |
| 26 | 26 | |
| 27 | /** How a node follows its parent: `offers` is the stored `tell-me`; the root of what is visible follows nothing. */ | |
| 28 | export type TreeFollow = "take-updates" | "offers" | "autonomous"; | |
| 27 | /** | |
| 28 | * How a node follows its parent: `offers` is the stored `tell-me`, `dont-follow` a fork | |
| 29 | * that chose to follow nothing; the root of what is visible follows nothing. | |
| 30 | */ | |
| 31 | export type TreeFollow = "take-updates" | "offers" | "dont-follow" | "autonomous"; | |
| 29 | 32 | |
| 30 | 33 | export interface TreeNode { |
| 31 | 34 | readonly repo: string; |
| @@ -101,7 +104,11 @@ export function forkTree( | ||
| 101 | 104 | repo: nameOf(r.repo), |
| 102 | 105 | parent: r.parent === null ? null : nameOf(r.parent), |
| 103 | 106 | depth: r.depth, |
| 104 | follow: r.parent === null ? "autonomous" : modes.get(r.repo.id) === "take-updates" ? "take-updates" : "offers", | |
| 107 | follow: r.parent === null ? "autonomous" : treeFollow(modes.get(r.repo.id)), | |
| 105 | 108 | })); |
| 106 | 109 | return { root: nameOf(root), nodes, truncated }; |
| 107 | 110 | } |
| 111 | ||
| 112 | /** A stored mode as the tree names it: `tell-me`, stored or by default, is `offers`. */ | |
| 113 | const treeFollow = (mode: string | undefined): TreeFollow => | |
| 114 | mode === "take-updates" || mode === "dont-follow" ? mode : "offers"; |
src/core/following.ts +4 −1
| @@ -109,7 +109,9 @@ export function follow(forge: Following): () => void { | ||
| 109 | 109 | }), |
| 110 | 110 | ); |
| 111 | 111 | if (moved.some((m) => m.ref === `refs/heads/${repo.defaultBranch}`)) |
| 112 | for (const fork of listForks(forge.db, repo.id)) | |
| 112 | for (const fork of listForks(forge.db, repo.id)) { | |
| 113 | // A fork that chose not to follow (#608) is neither moved nor told. | |
| 114 | if (forkFollowMode(forge.db, fork.id) === "dont-follow") continue; | |
| 113 | 115 | fastForwardFork(forge, fork, repo, actor) |
| 114 | 116 | .then(async (followed) => { |
| 115 | 117 | // A fork with work of its own cannot be moved; it is told the |
| @@ -140,6 +142,7 @@ export function follow(forge: Following): () => void { | ||
| 140 | 142 | error: String(error), |
| 141 | 143 | }), |
| 142 | 144 | ); |
| 145 | } | |
| 143 | 146 | }); |
| 144 | 147 | } |
| 145 | 148 |
src/core/fork_follow.ts +16 −6
| @@ -1,24 +1,34 @@ | ||
| 1 | 1 | /** How a plain fork follows its parent: one row per fork that chose; none means the default. */ |
| 2 | 2 | |
| 3 | 3 | import type { Database } from "bun:sqlite"; |
| 4 | import { DEFAULT_MODE, type FollowMode } from "../spawn/chain.ts"; | |
| 4 | import { DEFAULT_MODE, FOLLOW_MODES } from "../spawn/chain.ts"; | |
| 5 | 5 | |
| 6 | export function forkFollowMode(db: Database, repoId: string): FollowMode { | |
| 7 | const row = db.query<{ mode: FollowMode }, [string]>("SELECT mode FROM fork_follow WHERE repo_id = ?1").get(repoId); | |
| 6 | /** | |
| 7 | * A plain fork's modes: the chain's two, and `dont-follow` (#608), which a | |
| 8 | * spawned forge has no need of because it severs instead. | |
| 9 | */ | |
| 10 | export const FORK_FOLLOW_MODES = [...FOLLOW_MODES, "dont-follow"] as const; | |
| 11 | export type ForkFollowMode = (typeof FORK_FOLLOW_MODES)[number]; | |
| 12 | ||
| 13 | export const isForkFollowMode = (v: unknown): v is ForkFollowMode => | |
| 14 | typeof v === "string" && (FORK_FOLLOW_MODES as readonly string[]).includes(v); | |
| 15 | ||
| 16 | export function forkFollowMode(db: Database, repoId: string): ForkFollowMode { | |
| 17 | const row = db.query<{ mode: ForkFollowMode }, [string]>("SELECT mode FROM fork_follow WHERE repo_id = ?1").get(repoId); | |
| 8 | 18 | return row?.mode ?? DEFAULT_MODE; |
| 9 | 19 | } |
| 10 | 20 | |
| 11 | export function setForkFollowMode(db: Database, repoId: string, mode: FollowMode): void { | |
| 21 | export function setForkFollowMode(db: Database, repoId: string, mode: ForkFollowMode): void { | |
| 12 | 22 | db.query( |
| 13 | 23 | "INSERT INTO fork_follow (repo_id, mode) VALUES (?1, ?2) ON CONFLICT(repo_id) DO UPDATE SET mode = excluded.mode", |
| 14 | 24 | ).run(repoId, mode); |
| 15 | 25 | } |
| 16 | 26 | |
| 17 | 27 | /** The stored modes of these repos in one query; a repo with none stored is absent (it follows `DEFAULT_MODE`). */ |
| 18 | export function forkFollowModes(db: Database, repoIds: readonly string[]): Map<string, FollowMode> { | |
| 28 | export function forkFollowModes(db: Database, repoIds: readonly string[]): Map<string, ForkFollowMode> { | |
| 19 | 29 | if (repoIds.length === 0) return new Map(); |
| 20 | 30 | const rows = db |
| 21 | .query<{ repo_id: string; mode: FollowMode }, string[]>( | |
| 31 | .query<{ repo_id: string; mode: ForkFollowMode }, string[]>( | |
| 22 | 32 | `SELECT repo_id, mode FROM fork_follow WHERE repo_id IN (${repoIds.map(() => "?").join(",")})`, |
| 23 | 33 | ) |
| 24 | 34 | .all(...repoIds); |
src/core/migration-pins/20261110170000-fork-follow-dont-follow.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 596f05f01516a33179a8f6a65f86497f7cd031e251710d85ada2f13a45b8fb14 |
src/core/migrations/20261110170000-fork-follow-dont-follow.ts added +12
| @@ -0,0 +1,12 @@ | ||
| 1 | // A plain fork may also choose not to follow its parent at all (#608): | |
| 2 | // nothing is fast-forwarded, merged or told. SQLite cannot widen a CHECK in | |
| 3 | // place, so the table is rebuilt with every row kept. | |
| 4 | export default ` | |
| 5 | CREATE TABLE fork_follow_new ( | |
| 6 | repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE, | |
| 7 | mode TEXT NOT NULL CHECK (mode IN ('tell-me','take-updates','dont-follow')) | |
| 8 | ); | |
| 9 | INSERT INTO fork_follow_new (repo_id, mode) SELECT repo_id, mode FROM fork_follow; | |
| 10 | DROP TABLE fork_follow; | |
| 11 | ALTER TABLE fork_follow_new RENAME TO fork_follow; | |
| 12 | `; |
src/crew/converge.ts +3
| @@ -36,6 +36,7 @@ import { closesFromBody, listPulls, type Pull } from "../core/pulls.ts"; | ||
| 36 | 36 | import { canWrite, getRepoById, listAllForks, type Repo } from "../core/repos.ts"; |
| 37 | 37 | import { activeRunFor, cancelRun, listRuns } from "../core/runs.ts"; |
| 38 | 38 | import { commitIdentity } from "../core/commit_identity.ts"; |
| 39 | import { forkFollowMode } from "../core/fork_follow.ts"; | |
| 39 | 40 | import { getUserById, type User } from "../core/users.ts"; |
| 40 | 41 | import { runGit } from "../git/env.ts"; |
| 41 | 42 | import { isSafeOid } from "../git/guard.ts"; |
| @@ -226,6 +227,8 @@ export function followDivergence(app: App): () => void { | ||
| 226 | 227 | typeof parentId === "string" ? getRepoById(app.db, parentId) : undefined; |
| 227 | 228 | if (fork === undefined || parent === undefined) return; |
| 228 | 229 | if (fork.autonomy === "off") return; |
| 230 | // A fork that chose not to follow is never converged on its behalf. | |
| 231 | if (forkFollowMode(app.db, fork.id) === "dont-follow") return; | |
| 229 | 232 | diverged(app, fork, parent).catch((error) => |
| 230 | 233 | log.error("could not act on a diverged fork", { |
| 231 | 234 | fork: `${fork.ownerName}/${fork.name}`, |
src/http/mcp/tools/follow_mode.ts +4 −4
| @@ -1,18 +1,18 @@ | ||
| 1 | 1 | import { FOLLOW_MODE_REFUSALS, followModeSaid, setFollowMode } from "../../../actions/follow.ts"; |
| 2 | 2 | import { forkParentFor } from "../../../core/repos.ts"; |
| 3 | import { FOLLOW_MODES } from "../../../spawn/chain.ts"; | |
| 3 | import { FORK_FOLLOW_MODES } from "../../../core/fork_follow.ts"; | |
| 4 | 4 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "follow_mode", |
| 8 | doc: "`{ repo, mode }`: choose how a spawned forge follows its parent, `mode` being `tell-me` or `take-updates`; its `forge` repository's admin only; answers `{ says, mode }`; refused `unauthorized`, `forbidden`, `not_following`, `invalid_mode` or `severed`", | |
| 8 | doc: "`{ repo, mode }`: choose how a fork follows its parent: `take-updates`, `tell-me` or `dont-follow` (a spawned forge: the first two; it severs instead); the admin only; answers `{ says, mode }`; refused `unauthorized`, `forbidden`, `not_following`, `invalid_mode` or `severed`", | |
| 9 | 9 | description: |
| 10 | "How a spawned forge follows its parent: `tell-me` or `take-updates`.", | |
| 10 | "How a fork follows its parent: take updates, tell me, or don't follow.", | |
| 11 | 11 | inputSchema: { |
| 12 | 12 | type: "object", |
| 13 | 13 | properties: { |
| 14 | 14 | repo: REPO_ARG, |
| 15 | mode: { type: "string", enum: [...FOLLOW_MODES] }, | |
| 15 | mode: { type: "string", enum: [...FORK_FOLLOW_MODES] }, | |
| 16 | 16 | }, |
| 17 | 17 | required: ["repo", "mode"], |
| 18 | 18 | }, |
src/http/mcp/tools/following.ts +3 −1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { upstreamPayload } from "../../../spawn/upstream.ts"; |
| 2 | 2 | import { relationOf } from "../../../actions/forks.ts"; |
| 3 | import { followModeOf } from "../../../actions/follow.ts"; | |
| 3 | 4 | import { chainOfRepo } from "../../../spawn/chain.ts"; |
| 4 | 5 | import { convergeState } from "../../../crew/converge.ts"; |
| 5 | 6 | import { parentForgeFor } from "../../../core/parent_releases.ts"; |
| @@ -8,7 +9,7 @@ import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts" | ||
| 8 | 9 | export const tool: Tool = { |
| 9 | 10 | name: "following", |
| 10 | 11 | doc: |
| 11 | "`{ repo }`: for a fork `{ parent, state, behind, ahead, clean, conflicts, action, words, parentForge, converge, chain, severedAt, upstream }`, where it stands against what it follows (`state` in step, behind, ahead or diverged; `behind`/`ahead` commits; `clean` whether a merge would apply; `conflicts` the files that clash; `action` the one thing that reconciles it (none, fast-forward, offer, merge, keep-both) and `words` it said plainly; `converge` what the crew is doing about a conflict, else `null`; `parentForge`, `chain`, `severedAt` and `upstream` the forge-to-forge link, `null` when there is none); for a repository that is no fork but a spawned forge, only `{ chain, severedAt, upstream }`; refused `not_a_fork` when it follows nothing, `not_found` for a repository the caller may not see", | |
| 12 | "`{ repo }`: for a fork `{ parent, mode, state, behind, ahead, clean, conflicts, action, words, parentForge, converge, chain, severedAt, upstream }`, where it stands against what it follows (`mode` take-updates, tell-me or dont-follow, set by `follow_mode`; `state` in step, behind, ahead or diverged; `behind`/`ahead` commits; `clean` whether a merge would apply; `conflicts` the files that clash; `action` the one thing that reconciles it (none, fast-forward, offer, merge, keep-both) and `words` it said plainly; `converge` what the crew is doing about a conflict, else `null`; `parentForge`, `chain`, `severedAt` and `upstream` the forge-to-forge link, `null` when there is none); for a repository that is no fork but a spawned forge, only `{ chain, severedAt, upstream }`; refused `not_a_fork` when it follows nothing, `not_found` for a repository the caller may not see", | |
| 12 | 13 | description: |
| 13 | 14 | "Where a fork stands against what it follows: in step, behind, ahead, diverged.", |
| 14 | 15 | inputSchema: { |
| @@ -35,6 +36,7 @@ export const tool: Tool = { | ||
| 35 | 36 | severedAt: chain?.severedAt ?? null, |
| 36 | 37 | upstream: chain === undefined ? null : await upstreamPayload(ctx.app), |
| 37 | 38 | parent: `${relation.parent.ownerName}/${relation.parent.name}`, |
| 39 | mode: await followModeOf(ctx.app, repo), | |
| 38 | 40 | state: relation.state, |
| 39 | 41 | behind: relation.behind, |
| 40 | 42 | ahead: relation.ahead, |
src/http/mcp/tools/fork_tree.ts +1 −1
| @@ -4,7 +4,7 @@ import { good, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; | ||
| 4 | 4 | /** The whole tree a repo sits in, as the caller may read it. */ |
| 5 | 5 | export const tool: Tool = { |
| 6 | 6 | name: "fork_tree", |
| 7 | doc: "`{ repo, depth?, limit? }`: `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`, the fork tree around a repo from its topmost readable ancestor down: ancestors, siblings and descendants the caller may read, nothing else; `follow` is `autonomous` (the root), `take-updates` or `offers`; `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown", | |
| 7 | doc: "`{ repo, depth?, limit? }`: `{ root, nodes: [{ repo, parent, depth, follow }], truncated }`, the fork tree around a repo from its topmost readable ancestor down: ancestors, siblings and descendants the caller may read, nothing else; `follow` is `autonomous` (the root), `take-updates`, `offers` or `dont-follow`; `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown", | |
| 8 | 8 | description: "The fork tree around a repo: ancestors, siblings and descendants you may read.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
src/tui/screens/forks.ts +5 −4
| @@ -24,7 +24,7 @@ import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words | ||
| 24 | 24 | import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; |
| 25 | 25 | import type { Pull } from "../../core/pulls.ts"; |
| 26 | 26 | import { convergeState, type Converge } from "../../crew/converge.ts"; |
| 27 | import type { FollowMode } from "../../spawn/chain.ts"; | |
| 27 | import { FORK_FOLLOW_MODES, type ForkFollowMode } from "../../core/fork_follow.ts"; | |
| 28 | 28 | import { canAdmin, canRead, canWrite, forkTree, listForks, type ForkNode, type Repo } from "../../core/repos.ts"; |
| 29 | 29 | import type { Ctx, Screen } from "../app/app.ts"; |
| 30 | 30 | import { binding } from "../app/keys.ts"; |
| @@ -83,7 +83,7 @@ export function forkRows( | ||
| 83 | 83 | export function followingRows( |
| 84 | 84 | relation: ForkRelation | undefined, |
| 85 | 85 | converge: Converge | undefined, |
| 86 | mode?: FollowMode, | |
| 86 | mode?: ForkFollowMode, | |
| 87 | 87 | ): ForkRow[] { |
| 88 | 88 | if (relation === undefined) return []; |
| 89 | 89 | const { parent } = relation; |
| @@ -145,7 +145,7 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 145 | 145 | let relation: ForkRelation | undefined; |
| 146 | 146 | let offer: string | undefined; |
| 147 | 147 | let open: Pull | undefined; |
| 148 | let mode: FollowMode | undefined; | |
| 148 | let mode: ForkFollowMode | undefined; | |
| 149 | 149 | const state = new ListState(); |
| 150 | 150 | /** Only a person who writes to a fork may propose its work upstream. */ |
| 151 | 151 | const mayPropose = () => f.user !== undefined && f.user.kind !== "agent" && canWrite(f.db, repo, f.user); |
| @@ -301,7 +301,8 @@ export function forksScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 301 | 301 | } |
| 302 | 302 | if (e.key === "m" && canSetMode()) { |
| 303 | 303 | runAsync(ctx, async () => { |
| 304 | const next: FollowMode = mode === "take-updates" ? "tell-me" : "take-updates"; | |
| 304 | // `m` steps through the three modes in turn. | |
| 305 | const next = FORK_FOLLOW_MODES[(FORK_FOLLOW_MODES.indexOf(mode ?? "tell-me") + 1) % FORK_FOLLOW_MODES.length]!; | |
| 305 | 306 | const set = await setFollowMode(f.app, f.user, repo, next); |
| 306 | 307 | if (!set.ok) return ctx.toast(FOLLOW_MODE_REFUSALS[set.error].message, "danger"); |
| 307 | 308 | await read(); |
src/web/pages/following.ts +15 −12
| @@ -37,8 +37,8 @@ import { | ||
| 37 | 37 | type ParentReleases, |
| 38 | 38 | } from "../../core/parent_releases.ts"; |
| 39 | 39 | import { running } from "../../kernel/version.ts"; |
| 40 | import { followModeWords } from "../../actions/follow_words.ts"; | |
| 41 | import type { FollowMode } from "../../spawn/chain.ts"; | |
| 40 | import { FOLLOW_SWITCH_LABELS, followModeWords } from "../../actions/follow_words.ts"; | |
| 41 | import type { ForkFollowMode } from "../../core/fork_follow.ts"; | |
| 42 | 42 | import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts"; |
| 43 | 43 | import { convergeWords, followModeOf, takeOrTell, takeWords } from "../../actions/follow.ts"; |
| 44 | 44 | import { FOLLOWS_NOTHING, proposeRefusalWords } from "../../actions/follow_words.ts"; |
| @@ -95,7 +95,7 @@ export interface Following { | ||
| 95 | 95 | /** The parent forge's releases, when this forge names a parent forge. */ |
| 96 | 96 | readonly releases: { url: string; view: ParentReleases } | undefined; |
| 97 | 97 | /** How this repository follows its parent (`followModeOf`). */ |
| 98 | readonly mode: FollowMode; | |
| 98 | readonly mode: ForkFollowMode; | |
| 99 | 99 | } |
| 100 | 100 | |
| 101 | 101 | /** Everything the card needs, for a repo that follows something. */ |
| @@ -187,8 +187,8 @@ export function relationBadge(relation: ForkRelation): Html { | ||
| 187 | 187 | : relation.state === "ahead" |
| 188 | 188 | ? [`${relation.ahead} ahead`, "default"] |
| 189 | 189 | : relation.clean |
| 190 | ? ["diverged · merges cleanly", "attn"] | |
| 191 | : ["diverged", "attn"]; | |
| 190 | ? [`diverged · ${relation.ahead} ahead · ${relation.behind} behind · merges cleanly`, "attn"] | |
| 191 | : [`diverged · ${relation.ahead} ahead · ${relation.behind} behind`, "attn"]; | |
| 192 | 192 | return badge(text, { tone }); |
| 193 | 193 | } |
| 194 | 194 | |
| @@ -228,7 +228,8 @@ export function followingCard( | ||
| 228 | 228 | ctx: Ctx, |
| 229 | 229 | repo: Repo, |
| 230 | 230 | following: Following, |
| 231 | o: { bare?: boolean } = {}, | |
| 231 | /** `bare` drops the card's margin; `family: false` drops the link to the forks page, for the forks page itself. */ | |
| 232 | o: { bare?: boolean; family?: boolean } = {}, | |
| 232 | 233 | ): Html { |
| 233 | 234 | const { relation, converge } = following; |
| 234 | 235 | const parent = relation.parent; |
| @@ -292,7 +293,7 @@ export function followingCard( | ||
| 292 | 293 | icon: "fork", |
| 293 | 294 | title: `Your account can only read ${parent.ownerName}/${parent.name}'s origin, or its operator protects it: the change goes to your own fork there, and the pull opens from it`, |
| 294 | 295 | }) |
| 295 | : btn("Propose upstream", { | |
| 296 | : btn("Offer this change", { | |
| 296 | 297 | size: "sm", |
| 297 | 298 | type: "submit", |
| 298 | 299 | icon: "merge", |
| @@ -376,14 +377,16 @@ export function followingCard( | ||
| 376 | 377 | html`<p data-follow-mode="${following.mode}"> |
| 377 | 378 | <strong>${words.line}</strong>. |
| 378 | 379 | ${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 | }) | |
| 380 | ? words.others.map((m) => | |
| 381 | btn(FOLLOW_SWITCH_LABELS[m], { | |
| 382 | href: `/-/confirm?to=${encodeURIComponent(`${base}/following/mode/${m}`)}`, | |
| 383 | size: "sm", | |
| 384 | }), | |
| 385 | ) | |
| 383 | 386 | : null} |
| 384 | 387 | </p>`, |
| 385 | 388 | ); |
| 386 | if (followers > 0) | |
| 389 | if (followers > 0 && o.family !== false) | |
| 387 | 390 | rows.push( |
| 388 | 391 | row({ |
| 389 | 392 | href: `${base}/forks`, |
src/web/pages/repo.ts +10
| @@ -40,6 +40,7 @@ import { | ||
| 40 | 40 | archivedParentLine, |
| 41 | 41 | followingCard, |
| 42 | 42 | followingOf, |
| 43 | type Following, | |
| 43 | 44 | lineageCard, |
| 44 | 45 | } from "./following.ts"; |
| 45 | 46 | import { nothingNew } from "../../actions/follow.ts"; |
| @@ -172,8 +173,17 @@ export async function forksPage(ctx: Ctx): Promise<Response> { | ||
| 172 | 173 | t = performance.now(); |
| 173 | 174 | const ranked = await rankedForksOf(ctx.app, repo, forks); |
| 174 | 175 | notePhase(ctx.request, "drift", performance.now() - t); |
| 176 | // On a fork its admin may steer, the Following card: how this fork | |
| 177 | // follows, and its change offered up when ahead (#608). | |
| 178 | let following: Following | undefined; | |
| 179 | if (repo.forkParentId !== null && canAdmin(ctx.app.db, repo, ctx.auth.user)) { | |
| 180 | t = performance.now(); | |
| 181 | following = await followingOf(ctx, repo); | |
| 182 | notePhase(ctx.request, "following", performance.now() - t); | |
| 183 | } | |
| 175 | 184 | t = performance.now(); |
| 176 | 185 | const body = html`${repoHeader(ctx, repo, "forks")} |
| 186 | ${following === undefined ? "" : followingCard(ctx, repo, following, { family: false })} | |
| 177 | 187 | <div class="page-head mt"> |
| 178 | 188 | <h2>${forks.length} fork${forks.length === 1 ? "" : "s"}</h2> |
| 179 | 189 | </div> |
src/web/pages/search.ts +3 −3
| @@ -19,7 +19,7 @@ import { openLabelCounts } from "../../core/issues.ts"; | ||
| 19 | 19 | import { getPull } from "../../core/pulls.ts"; |
| 20 | 20 | import { canAdmin, canWrite, enterableApps, forkParentFor, getRepo } from "../../core/repos.ts"; |
| 21 | 21 | import { followModeWords, followSwitchPreview } from "../../actions/follow_words.ts"; |
| 22 | import type { FollowMode } from "../../spawn/chain.ts"; | |
| 22 | import type { ForkFollowMode } from "../../core/fork_follow.ts"; | |
| 23 | 23 | import { getMirrorTarget } from "../../core/migrate_mirror.ts"; |
| 24 | 24 | import { |
| 25 | 25 | cleanQuery, |
| @@ -914,9 +914,9 @@ const DESTRUCTIVE: { | ||
| 914 | 914 | }, |
| 915 | 915 | }, |
| 916 | 916 | { |
| 917 | pattern: /^\/([^/]+)\/([^/]+)\/following\/mode\/(tell-me|take-updates)$/, | |
| 917 | pattern: /^\/([^/]+)\/([^/]+)\/following\/mode\/(tell-me|take-updates|dont-follow)$/, | |
| 918 | 918 | describe: (m, ctx) => { |
| 919 | const mode = m[3] as FollowMode; | |
| 919 | const mode = m[3] as ForkFollowMode; | |
| 920 | 920 | const repo = getRepo(ctx.app.db, m[1]!, m[2]!); |
| 921 | 921 | const parent = repo && canWrite(ctx.app.db, repo, ctx.auth.user) ? forkParentFor(ctx.app.db, repo, ctx.auth.user) : undefined; |
| 922 | 922 | const upstream = parent === undefined ? "its parent" : `${parent.ownerName}/${parent.name}`; |
test/fork-follow-mode.test.ts +43 −1
| @@ -8,6 +8,7 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 8 | 8 | import { forkFollowMode } from "../src/core/fork_follow.ts"; |
| 9 | 9 | import { forkRelation } from "../src/core/following.ts"; |
| 10 | 10 | import { getRepo, type Repo } from "../src/core/repos.ts"; |
| 11 | import { listEvents } from "../src/core/events.ts"; | |
| 11 | 12 | import { createUser, type User } from "../src/core/users.ts"; |
| 12 | 13 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 13 | 14 | import { forksScreen } from "../src/tui/screens/forks.ts"; |
| @@ -33,7 +34,7 @@ const commit = async (who: string, repo: Repo, path: string) => | ||
| 33 | 34 | beforeAll(async () => { |
| 34 | 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-fork-follow-mode-")); |
| 35 | 36 | app = createApp({ dataDir, port: 0 }); |
| 36 | for (const name of ["ann", "bea", "cal"]) { | |
| 37 | for (const name of ["ann", "bea", "cal", "dee", "eve"]) { | |
| 37 | 38 | users[name] = must( |
| 38 | 39 | await createUser(app.db, { |
| 39 | 40 | origin: "seed", |
| @@ -105,6 +106,47 @@ describe("the mode changes what happens when the parent moves", () => { | ||
| 105 | 106 | }); |
| 106 | 107 | }); |
| 107 | 108 | |
| 109 | describe("don't follow (#608)", () => { | |
| 110 | const settle = async (f: Repo, state: string) => { | |
| 111 | for (let i = 0; i < 100; i++) { | |
| 112 | if ((await forkRelation(app, f, original))?.state === state) return true; | |
| 113 | await Bun.sleep(50); | |
| 114 | } | |
| 115 | return false; | |
| 116 | }; | |
| 117 | ||
| 118 | test("is a mode the owner may choose and a stranger may not", async () => { | |
| 119 | const own = must(await forkRepo(app, users["cal"]!, original, "yard-alone")); | |
| 120 | expect(await setFollowMode(app, users["bea"]!, own, "dont-follow")).toEqual({ ok: false, error: "forbidden" }); | |
| 121 | expect(must(await setFollowMode(app, users["cal"]!, own, "dont-follow"))).toBe("dont-follow"); | |
| 122 | expect(forkFollowMode(app.db, own.id)).toBe("dont-follow"); | |
| 123 | }); | |
| 124 | ||
| 125 | test("the parent moving neither moves the fork nor tells anyone; tell-me beside it still fast-forwards", async () => { | |
| 126 | const alone = getRepo(app.db, "cal", "yard-alone")!; | |
| 127 | const told = must(await forkRepo(app, users["dee"]!, original, "yard-told")); | |
| 128 | must(await setFollowMode(app, users["dee"]!, told, "tell-me")); | |
| 129 | const diverged = () => listEvents(app.db, { kind: "fork.diverged" }).filter((e) => e.repoId === alone.id).length; | |
| 130 | await commit("cal", alone, "own.txt"); | |
| 131 | const before = diverged(); | |
| 132 | await commit("ann", original, "three.txt"); | |
| 133 | // The tell-me fork, with nothing of its own, catches up on its own. | |
| 134 | // Its fast-forward starts in the same synchronous pass of `follow` that | |
| 135 | // skips a dont-follow fork, and a skipped fork starts no work at all; so | |
| 136 | // once the sibling has settled, the handler is done with this fork too. | |
| 137 | expect(await settle(told, "synced")).toBe(true); | |
| 138 | expect((await forkRelation(app, alone, original))?.state).toBe("diverged"); | |
| 139 | expect(diverged()).toBe(before); | |
| 140 | ||
| 141 | // With nothing of its own it would have been fast-forwarded; it is not. | |
| 142 | const bare = must(await forkRepo(app, users["eve"]!, original, "yard-bare")); | |
| 143 | must(await setFollowMode(app, users["eve"]!, bare, "dont-follow")); | |
| 144 | await commit("ann", original, "four.txt"); | |
| 145 | expect(await settle(told, "synced")).toBe(true); | |
| 146 | expect((await forkRelation(app, bare, original))?.state).toBe("behind"); | |
| 147 | }); | |
| 148 | }); | |
| 149 | ||
| 108 | 150 | describe("the terminal", () => { |
| 109 | 151 | const open = async (who: string, keys: string) => { |
| 110 | 152 | const terminal = headlessTerminal(120, 30); |
test/fork-tree-web.test.ts +68 −8
| @@ -2,11 +2,8 @@ | ||
| 2 | 2 | * #7756: the Fork tree test, on the web, on one forge. A person looks at a |
| 3 | 3 | * fork's page and sees its parent and its child as a tree, chooses how it |
| 4 | 4 | * follows, and offers its own commit up in one POST from the offer page. |
| 5 | * | |
| 6 | * The mode step is covered only for the forge's own code: the follow modes | |
| 7 | * exist nowhere else, so the middle fork is made that repository by hand. | |
| 8 | * A plain in-forge fork offers no mode choice; that step is missing, and is | |
| 9 | * #7796. | |
| 5 | * A plain in-forge fork chooses among three modes on its page and its forks | |
| 6 | * page (#7796, #608). | |
| 10 | 7 | */ |
| 11 | 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 12 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| @@ -20,7 +17,8 @@ import { createUser, type User } from "../src/core/users.ts"; | ||
| 20 | 17 | import { serve, type Servers } from "../src/http/server.ts"; |
| 21 | 18 | import { forkFollowMode } from "../src/core/fork_follow.ts"; |
| 22 | 19 | import { listEvents } from "../src/core/events.ts"; |
| 23 | import { followModeSaid } from "../src/actions/follow.ts"; | |
| 20 | import { createToken } from "../src/core/tokens.ts"; | |
| 21 | import { followModeSaid, setFollowMode } from "../src/actions/follow.ts"; | |
| 24 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| 25 | 23 | |
| 26 | 24 | quietLogs(); |
| @@ -181,6 +179,63 @@ describe("the fork tree, on one forge", () => { | ||
| 181 | 179 | expect(followModeSaid(undefined, "tell-me")).toContain("Proposes updates from its parent."); |
| 182 | 180 | }); |
| 183 | 181 | |
| 182 | test("the forks page lets the fork's owner choose any of three modes, and not a reader (#608)", async () => { | |
| 183 | const cookie = await signIn("bea"); | |
| 184 | const forks = await (await get("/bea/yard/forks", cookie)).text(); | |
| 185 | expect(forks).toContain('id="following"'); | |
| 186 | expect(forks).toContain("data-follow-mode="); | |
| 187 | // The card on its own forks page does not link back to it. | |
| 188 | expect(forks).not.toContain("forks follow this one"); | |
| 189 | const now = forkFollowMode(app.db, middle.id); | |
| 190 | const others = (["take-updates", "tell-me", "dont-follow"] as const).filter((m) => m !== now); | |
| 191 | for (const m of others) expect(forks).toContain(`following%2Fmode%2F${m}"`); | |
| 192 | expect(forks).not.toContain(`following%2Fmode%2F${now}"`); | |
| 193 | ||
| 194 | const reader = await (await get("/bea/yard/forks", await signIn("cal"))).text(); | |
| 195 | expect(reader).not.toContain('id="following"'); | |
| 196 | expect(reader).toContain('aria-label="Forks, by who follows whom"'); | |
| 197 | ||
| 198 | const confirm = await (await get("/-/confirm?to=%2Fbea%2Fyard%2Ffollowing%2Fmode%2Fdont-follow", cookie)).text(); | |
| 199 | expect(confirm).toContain("Does not follow ann/yard?"); | |
| 200 | expect(confirm).toContain("the crew is never asked to converge"); | |
| 201 | const changed = await post("/bea/yard/following/mode/dont-follow", cookie, {}); | |
| 202 | expect(changed.status).toBe(303); | |
| 203 | expect(forkFollowMode(app.db, middle.id)).toBe("dont-follow"); | |
| 204 | const after = await (await get("/bea/yard/forks", cookie)).text(); | |
| 205 | expect(after).toContain("Does not follow ann/yard"); | |
| 206 | expect(after).toContain("Follow automatically"); | |
| 207 | expect(after).toContain("Only propose updates"); | |
| 208 | expect(after).not.toContain("Stop following"); | |
| 209 | must(await setFollowMode(app, bea, middle, "tell-me")); | |
| 210 | }); | |
| 211 | ||
| 212 | test("MCP: following returns the mode, follow_mode sets any of the three; a stranger is refused (#608)", async () => { | |
| 213 | let id = 1; | |
| 214 | const call = async (who: User, name: string, args: unknown) => { | |
| 215 | const res = await fetch(`${servers.url}/mcp`, { | |
| 216 | method: "POST", | |
| 217 | headers: { | |
| 218 | "content-type": "application/json", | |
| 219 | accept: "application/json, text/event-stream", | |
| 220 | authorization: `Bearer ${createToken(app.db, who.id, "t", "rw").token}`, | |
| 221 | }, | |
| 222 | body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }), | |
| 223 | }); | |
| 224 | const j = (await res.json()) as any; | |
| 225 | return { error: j.result.isError === true, text: String(j.result.content[0].text) }; | |
| 226 | }; | |
| 227 | const set = await call(bea, "follow_mode", { repo: "bea/yard", mode: "dont-follow" }); | |
| 228 | expect(set.error).toBe(false); | |
| 229 | expect(JSON.parse(set.text).mode).toBe("dont-follow"); | |
| 230 | const read = await call(bea, "following", { repo: "bea/yard" }); | |
| 231 | expect(JSON.parse(read.text).mode).toBe("dont-follow"); | |
| 232 | const tree = await call(bea, "fork_tree", { repo: "bea/yard" }); | |
| 233 | expect(JSON.parse(tree.text).nodes.find((n: { repo: string }) => n.repo === "bea/yard").follow).toBe("dont-follow"); | |
| 234 | expect((await call(cal, "follow_mode", { repo: "bea/yard", mode: "take-updates" })).error).toBe(true); | |
| 235 | expect(forkFollowMode(app.db, middle.id)).toBe("dont-follow"); | |
| 236 | must(await setFollowMode(app, bea, middle, "tell-me")); | |
| 237 | }); | |
| 238 | ||
| 184 | 239 | test("one POST from the offer page opens exactly one pull on the parent", async () => { |
| 185 | 240 | const cookie = await signIn("bea"); |
| 186 | 241 | const fork = getRepo(app.db, "bea", "yard")!; |
| @@ -190,12 +245,17 @@ describe("the fork tree, on one forge", () => { | ||
| 190 | 245 | files: [{ path: "mine.txt", content: "m\n" }], |
| 191 | 246 | }), |
| 192 | 247 | ); |
| 193 | // The door is the Following card's "Propose upstream" form: read its | |
| 248 | // The door is the Following card's "Offer this change" form: read its | |
| 194 | 249 | // action and fields off the page, then send exactly that. |
| 195 | 250 | const home = await (await get("/bea/yard", cookie)).text(); |
| 196 | 251 | const formTag = home.match(/<form method="post" action="([^"]+)">\s*<input type="hidden" name="do" value="propose"/); |
| 197 | 252 | expect(formTag).not.toBeNull(); |
| 198 | expect(home).toContain("Propose upstream"); | |
| 253 | expect(home).toContain("Offer this change"); | |
| 254 | // The forks page carries the same card for its owner (#608): the | |
| 255 | // offer, and the tree saying the fork is one ahead. | |
| 256 | const forks = await (await get("/bea/yard/forks", cookie)).text(); | |
| 257 | expect(forks).toContain("Offer this change"); | |
| 258 | expect(forks.slice(forks.indexOf('aria-label="Forks, by who follows whom"'))).toContain("1 ahead"); | |
| 199 | 259 | const sent = await post(formTag![1]!, cookie, { do: "propose" }); |
| 200 | 260 | expect(sent.status).toBe(303); |
| 201 | 261 | expect(sent.headers.get("location")).toBe("/ann/yard/pulls/1"); |
test/schema-additive.test.ts +1
| @@ -161,6 +161,7 @@ const RETIRED: Record<string, { readonly drops: readonly string[]; readonly sinc | ||
| 161 | 161 | const REBUILT: Record<string, string> = { |
| 162 | 162 | "20261103120000-conversations-many": "conversation loses UNIQUE(user_id) (#8814); same columns, plus title and archived_at", |
| 163 | 163 | "20261104100000-conversation-run-stopped-state": "conversation_run widens its state CHECK to admit stopped (#8972); same columns, same rows, the stopped column kept", |
| 164 | "20261110170000-fork-follow-dont-follow": "fork_follow widens its mode CHECK to admit dont-follow (#608); same columns, same rows; the release before reads dont-follow as not take-updates", | |
| 164 | 165 | }; |
| 165 | 166 | |
| 166 | 167 | const src = join(import.meta.dir, "../src"); |
test/spawn-chain-mode.test.ts +3
| @@ -112,6 +112,9 @@ describe("choosing how a forge follows its parent", () => { | ||
| 112 | 112 | expect((await form(cookie, "yolo")).status).toBe(303); |
| 113 | 113 | expect((await tool(readerTok, { repo: "kid/forge", mode: "take-updates" })).error).toBe(true); |
| 114 | 114 | expect((await tool(ownerTok, { repo: "kid/forge", mode: "yolo" })).error).toBe(true); |
| 115 | // Not following is a plain fork's mode; a spawned forge severs instead (#608). | |
| 116 | expect((await patch(ownerTok, { mode: "dont-follow" })).status).toBe(422); | |
| 117 | expect((await tool(ownerTok, { repo: "kid/forge", mode: "dont-follow" })).error).toBe(true); | |
| 115 | 118 | const cross = await fetch(`${servers.url}/kid/forge/following/mode`, { |
| 116 | 119 | method: "POST", |
| 117 | 120 | headers: { cookie, origin: "https://evil.test" }, |
Fixed sleep to prove a negative is a flake source. Wait on a deterministic signal from the follow handler instead.