Public: anyone can read this; the crew can change it.
A fork takes an upstream change and offers its own back, proven end to end across two forges #12425
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12401-a-fork-takes-an-upstream-change-and-offe → main 4 commits fork
3 files
changed +159
−3
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`, `offers` or `dont-follow`; `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown; drawn as the `ui://forge/forks` card", | |
| 7 | doc: "`{ repo, depth?, limit? }`: `{ root, nodes: [{ repo, parent, depth, follow, word }], 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`, `word` its plain word (follows, proposes, severed); `depth` 1-8 (default 4), `limit` 1-200 nodes (default 200); `truncated` is true when more exists than is shown; drawn as the `ui://forge/forks` card", | |
| 8 | 8 | description: "The fork tree around a repo: ancestors, siblings and descendants you may read.", |
| 9 | 9 | inputSchema: { |
| 10 | 10 | type: "object", |
src/http/mcp/tools/propose_to_parent_forge.ts +19 −2
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | import { FAMILY_WORDS } from "../../../actions/family_words.ts"; |
| 2 | import { proposeToParentForge } from "../../../actions/upstream.ts"; | |
| 2 | import { proposeRefusalWords } from "../../../actions/follow_words.ts"; | |
| 3 | import { proposeToParentForge, proposeUpstream } from "../../../actions/upstream.ts"; | |
| 4 | import { forkParentFor } from "../../../core/repos.ts"; | |
| 3 | 5 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 4 | 6 | |
| 5 | 7 | export const tool: Tool = { |
| 6 | 8 | name: "propose_to_parent_forge", |
| 7 | doc: "`{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; the same tip answers with that pull", | |
| 9 | doc: "`{ repo }`: offer this forge's landed change to its parent as a pull on `child/<name>/…`; a fork whose parent is in this forge offers its default branch as a pull there; the same tip answers with that pull", | |
| 8 | 10 | description: |
| 9 | 11 | "Offer this forge's landed change to its parent as a pull on `child/<name>/…`", |
| 10 | 12 | inputSchema: { |
| @@ -16,6 +18,21 @@ export const tool: Tool = { | ||
| 16 | 18 | async run(ctx, user, args) { |
| 17 | 19 | const repo = repoArg(ctx, user, args); |
| 18 | 20 | if (isAnswer(repo)) return repo; |
| 21 | const parent = forkParentFor(ctx.app.db, repo, user); | |
| 22 | if (parent !== undefined) { | |
| 23 | const name = `${parent.ownerName}/${parent.name}`; | |
| 24 | const offered = await proposeUpstream(ctx.app, user, repo); | |
| 25 | if (!offered.ok) { | |
| 26 | const e = offered.error; | |
| 27 | return bad(typeof e === "string" ? e : "refused", proposeRefusalWords(e as never, name)); | |
| 28 | } | |
| 29 | const { pull, opened } = offered.value; | |
| 30 | return good({ | |
| 31 | says: `${opened ? "Proposed to" : "Already proposed to"} ${name} as #${pull.number}.`, | |
| 32 | opened, | |
| 33 | proposal: { number: pull.number, title: pull.title, state: "open" }, | |
| 34 | }); | |
| 35 | } | |
| 19 | 36 | const made = await proposeToParentForge(ctx.app, user, repo); |
| 20 | 37 | if (made.ok) |
| 21 | 38 | return good({ |
test/fork-flow-journey.test.ts added +139
| @@ -0,0 +1,139 @@ | ||
| 1 | /** | |
| 2 | * The fork loop, end to end (#12401): a change lands upstream and flows down as | |
| 3 | * an update the fork takes with one action; the fork's own commit goes back up | |
| 4 | * as an offer the upstream owner lands. Read through the doors a person and an | |
| 5 | * agent use: the following API, the page, the MCP, and the fork tree in the | |
| 6 | * words of `follow_words.ts`. Both repositories are in this process; the | |
| 7 | * cross-forge offer (`propose_to_parent_forge` between two forges) is walked in | |
| 8 | * `upstream-forge.test.ts`. | |
| 9 | */ | |
| 10 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { FOLLOW_WORD } from "../src/actions/follow_words.ts"; | |
| 15 | import { mergePull, openPull } from "../src/actions/pulls.ts"; | |
| 16 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 17 | import { createApp, type App } from "../src/app.ts"; | |
| 18 | import { getPull } from "../src/core/pulls.ts"; | |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 20 | import { createToken } from "../src/core/tokens.ts"; | |
| 21 | import { createUser, type User } from "../src/core/users.ts"; | |
| 22 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 23 | import { quietLogs } from "./helpers/globals.ts"; | |
| 24 | import { promote } from "./helpers/members.ts"; | |
| 25 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | let dir = ""; | |
| 30 | let app: App; | |
| 31 | let servers: Servers; | |
| 32 | let alice: User; | |
| 33 | let bob: User; | |
| 34 | let lib: Repo; | |
| 35 | let fork: Repo; | |
| 36 | let bobToken = ""; | |
| 37 | ||
| 38 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 39 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 40 | return r.value; | |
| 41 | }; | |
| 42 | ||
| 43 | const person = async (username: string, first: boolean): Promise<User> => { | |
| 44 | const user = must(await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@f.dev`, password: "a-good-passphrase", adminIfFirst: first })); | |
| 45 | promote(app.db, user); | |
| 46 | return user; | |
| 47 | }; | |
| 48 | ||
| 49 | const api = (path: string, token: string, method = "GET") => | |
| 50 | fetch(`${servers.url}${path}`, { method, headers: { authorization: `Bearer ${token}` } }); | |
| 51 | ||
| 52 | /** A change landing on `repo` the way a person lands one: a branch, a pull, a merge. */ | |
| 53 | async function land(owner: User, repo: Repo, branch: string, title: string): Promise<void> { | |
| 54 | must(await commitToBranch(app, owner, repo, { branch, base: "main", message: title, files: [{ path: `${branch}.md`, content: `${title}\n` }] })); | |
| 55 | const pull = must(await openPull(app, owner, repo, { title, baseBranch: "main", headBranch: branch })); | |
| 56 | must(await mergePull(app, owner, repo, pull, "merge")); | |
| 57 | } | |
| 58 | ||
| 59 | /** The fork's node in the tree the MCP draws, and the word it says. */ | |
| 60 | async function treeWord(): Promise<{ follow: string; word: string }> { | |
| 61 | const tree = await mcpTool(servers.url, bobToken, "fork_tree", { repo: "bob/lib" }); | |
| 62 | const node = tree.value.nodes.find((n: { repo: string }) => n.repo === "bob/lib"); | |
| 63 | return { follow: node.follow, word: node.word }; | |
| 64 | } | |
| 65 | ||
| 66 | beforeAll(async () => { | |
| 67 | dir = await mkdtemp(join(tmpdir(), "forge-fork-flow-")); | |
| 68 | app = createApp({ dataDir: dir, port: 0 }); | |
| 69 | servers = await serve(app); | |
| 70 | alice = await person("alice", true); | |
| 71 | bob = await person("bob", false); | |
| 72 | bobToken = createToken(app.db, bob.id, "t", "admin").token; | |
| 73 | lib = must(await createRepo(app, alice, { name: "lib", visibility: "public" })); | |
| 74 | must(await commitToBranch(app, alice, lib, { branch: "main", message: "Start", files: [{ path: "README.md", content: "hi\n" }] })); | |
| 75 | fork = must(await forkRepo(app, bob, lib)); | |
| 76 | }, 60_000); | |
| 77 | ||
| 78 | afterAll(async () => { | |
| 79 | servers?.stop(); | |
| 80 | app?.close(); | |
| 81 | await rm(dir, { recursive: true, force: true }); | |
| 82 | }); | |
| 83 | ||
| 84 | test("a new fork proposes by default, and says so in the tree's words", async () => { | |
| 85 | expect(await treeWord()).toEqual({ follow: "offers", word: FOLLOW_WORD["tell-me"] }); | |
| 86 | expect(FOLLOW_WORD["tell-me"]).toBe("proposes"); | |
| 87 | }); | |
| 88 | ||
| 89 | test("the fork has work of its own; a change lands upstream; the fork's update line says so and one action takes it", async () => { | |
| 90 | // A fork with nothing of its own is moved silently as the push lands (core/following.ts), so | |
| 91 | // the line below is for the fork that has its own work: both sides moved, git merges cleanly. | |
| 92 | must(await commitToBranch(app, bob, fork, { branch: "main", message: "Fork improvement", files: [{ path: "fork.md", content: "mine\n" }] })); | |
| 93 | await land(alice, lib, "upstream-feat", "Upstream feature"); | |
| 94 | const line = (await (await api("/api/v1/repos/bob/lib/following", bobToken)).json()) as { state: string; behind: number; ahead: number; clean: boolean; parent: string; words: string }; | |
| 95 | expect(line).toMatchObject({ state: "diverged", ahead: 1, clean: true, parent: "alice/lib" }); | |
| 96 | expect(line.behind).toBeGreaterThan(0); | |
| 97 | expect(line.words).toContain("git merges the two cleanly"); | |
| 98 | // The fork page's tree names the fork in the same word as the MCP's. | |
| 99 | const forks = await (await fetch(`${servers.url}/alice/lib/forks`, { headers: { cookie: "forge_mode=dev" } })).text(); | |
| 100 | expect(forks).toContain('href="/bob/lib"'); | |
| 101 | expect(forks).toContain(FOLLOW_WORD["tell-me"]); | |
| 102 | ||
| 103 | const took = await mcpTool(servers.url, bobToken, "take_updates", { repo: "bob/lib" }); | |
| 104 | expect(took.error).toBeUndefined(); | |
| 105 | expect(took.value).toMatchObject({ updated: true, merged: true }); | |
| 106 | const after = (await (await api("/api/v1/repos/bob/lib/following", bobToken)).json()) as { behind: number; ahead: number }; | |
| 107 | expect(after.behind).toBe(0); | |
| 108 | expect(after.ahead).toBeGreaterThan(0); | |
| 109 | }); | |
| 110 | ||
| 111 | test("the fork's own work is offered up through the MCP, and the upstream owner lands it", async () => { | |
| 112 | const offered = await mcpTool(servers.url, bobToken, "propose_to_parent_forge", { repo: "bob/lib" }); | |
| 113 | expect(offered.error).toBeUndefined(); | |
| 114 | expect(offered.value.opened).toBe(true); | |
| 115 | const number = offered.value.proposal.number as number; | |
| 116 | ||
| 117 | // Asking again is the same offer, not a second one. | |
| 118 | const again = await mcpTool(servers.url, bobToken, "propose_to_parent_forge", { repo: "bob/lib" }); | |
| 119 | expect(again.value).toMatchObject({ opened: false }); | |
| 120 | expect(again.value.proposal.number).toBe(number); | |
| 121 | ||
| 122 | const pull = getPull(app.db, lib.id, number)!; | |
| 123 | expect(pull.headRepoId).toBe(fork.id); | |
| 124 | must(await mergePull(app, alice, lib, pull, "merge")); | |
| 125 | expect(getPull(app.db, lib.id, number)!.mergedAt).not.toBeNull(); | |
| 126 | ||
| 127 | // Landed upstream: the fork has nothing left to offer. | |
| 128 | const left = (await (await api("/api/v1/repos/bob/lib/following", bobToken)).json()) as { ahead: number }; | |
| 129 | expect(left.ahead).toBe(0); | |
| 130 | }); | |
| 131 | ||
| 132 | test("the tree says follows when the fork takes updates, then proposes again", async () => { | |
| 133 | const set = (mode: string) => mcpTool(servers.url, bobToken, "follow_mode", { repo: "bob/lib", mode }); | |
| 134 | expect((await set("take-updates")).error).toBeUndefined(); | |
| 135 | expect(await treeWord()).toEqual({ follow: "take-updates", word: FOLLOW_WORD["take-updates"] }); | |
| 136 | expect(FOLLOW_WORD["take-updates"]).toBe("follows"); | |
| 137 | expect((await set("tell-me")).error).toBeUndefined(); | |
| 138 | expect(await treeWord()).toEqual({ follow: "offers", word: "proposes" }); | |
| 139 | }); |
new above
offline