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

Propose upstream across forges, part 2: from a child's landed change, one button pushes it to the parent and opens the pull, and the child shows its state #2218

Merged opened this

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

Merged

scout/forge:scout/1832-propose-upstream-across-forges-part-2-fr → main 4 commits fork 8 threads · 8 unresolved

  • Merged. scout/1832-propose-upstream-across-forges-part-2-fr was merged into main as 586d419.

Review

Sign in to review this change.
10 files changed +844 −19
README.md +18
@@ -104,6 +104,24 @@ opened with it says "proposed by child … from …" on every tab of the pull's
104104 page, in the terminal's pull view, and as `proposal` in the pull's JSON
105105 (`GET …/pulls/:number`, and MCP's `pull` tool).
106106
107 ### Proposing to a parent forge
108
109 A spawned forge can offer its own change to the forge it came from. The
110 parent mints a proposal token for the child (above); the child keeps it in
111 `parent.json` as `"proposal": {"child": "kid", "token": "…"}` beside the
112 parent's address. On the child's `/<org>/forge/following` page a writer
113 presses **Propose to the parent forge** (or `POST …/following/propose-parent`):
114 the default branch is pushed to `child/<name>/<branch>-<tip>` at the parent,
115 and the pull opens there, titled by the change that landed last and naming it
116 (`org/forge#7`). Asking again for the same tip returns the pull already made.
117
118 The page and `GET …/following/proposals` show each proposal as open, merged or
119 closed, read from the parent (the token may read back only pulls it opened) and
120 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
121 answer shows the last state with "parent unreachable" — never an error page.
122 In the terminal, the Chain card lists the proposals and `p` proposes. No dependency was added. Left out: an MCP tool, and a UI to paste
outdated

Leaving out the terminal door is a checklist miss: add propose and the proposals list to src/tui/screens/chain.ts next to sever, then remove this line.

123 the token into `parent.json` (part 2 of #2182 covers minting and handing over).
124
107125 Minting, listing and revoking go through one action
108126 (`src/actions/proposal_tokens.ts`) at every door, and only a writer of the
109127 repository may; anyone else is told "no such resource here, or it is not
src/actions/upstream.ts +216 −1
@@ -16,8 +16,12 @@ import {
1616 } from "../core/repos.ts";
1717 import { listPulls, mergedPullsForRepos, type Pull } from "../core/pulls.ts";
1818 import type { User } from "../core/users.ts";
19 import { aheadOids } from "../git/merge.ts";
19 import { aheadOids, resolveOid } from "../git/merge.ts";
2020 import { repoDir } from "../git/repos.ts";
21 import { basicHeader, gitAuthEnv, redact, secretsIn } from "../origins/credentials.ts";
22 import { pushToOrigin } from "../origins/git-mirror.ts";
23 import { forgeLinkOf } from "../spawn/chain.ts";
24 import { parentCredential, type ParentCredential } from "../spawn/parent_credential.ts";
2125 import { err, ok, type Result } from "../kernel/result.ts";
2226 import { requireWriter, type AccessError } from "./access.ts";
2327 import { openPull } from "./pulls.ts";
@@ -120,3 +124,214 @@ export async function proposeUpstream(
120124 });
121125 return ok({ pull: opened.value, opened: true, changes: landed.length });
122126 }
127
128 // ── Across forges. When the parent is another forge, the child pushes its
129 // change to `child/<name>/<branch>` there with the proposal token and opens
130 // the pull; the parent stays the truth and the child only caches its state.
131
132 export type ForgeProposalState = "open" | "merged" | "closed";
133
134 export interface ForgeProposal {
135 readonly number: number;
136 readonly title: string;
137 readonly branch: string;
138 readonly state: ForgeProposalState;
139 /** False when the parent did not answer the last time it was asked. */
140 readonly reachable: boolean;
141 /** The pull's page on the parent forge. */
142 readonly url: string;
143 }
144
145 export type ProposeForgeError =
146 | AccessError
147 | "no_parent_forge"
148 | "nothing_to_propose"
149 | { readonly kind: "refused"; readonly reason: string }
150 | { readonly kind: "unreachable" };
151
152 /** A cached state is believed this long before the parent is asked again. */
153 export const STATE_TTL_MS = 60_000;
154 const ASK_TIMEOUT_MS = 5_000;
155 /** Only the newest few proposals are asked about at once. */
156 const REFRESHED = 5;
157 const PUSH_TIMEOUT_MS = 60_000;
158
159 interface Row {
160 id: number;
161 branch: string;
162 number: number;
163 title: string;
164 source: string;
165 state: string;
166 reachable: number;
167 checked_at: number;
168 }
169
170 const asProposal = (cred: ParentCredential, r: Row): ForgeProposal => ({
171 number: r.number,
172 title: r.title,
173 branch: r.branch,
174 state: r.state === "merged" || r.state === "closed" ? r.state : "open",
175 reachable: r.reachable === 1,
176 url: `${cred.url}/${cred.repo}/pulls/${r.number}`,
177 });
178
179 const apiUrl = (cred: ParentCredential, tail: string): string =>
180 `${cred.url}/api/v1/repos/${cred.repo}/pulls${tail}`;
181
182 /** The parent's own words for a refusal, one line, with no secret in it. */
183 async function reasonOf(res: Response, cred: ParentCredential): Promise<string> {
184 const text = await res.text().catch(() => "");
185 let said = "";
186 try {
187 const d = JSON.parse(text) as { message?: unknown; error?: unknown };
188 said = typeof d.message === "string" ? d.message : typeof d.error === "string" ? d.error : "";
189 } catch {
190 // not JSON: the status alone will do
191 }
192 return redact(said || `status ${res.status}`, cred.token).split("\n")[0]!.slice(0, 200);
193 }
194
195 /**
196 * Push this forge's default branch to the parent as `child/<name>/<branch>`
197 * and open the pull there, naming the change that landed here. One proposal
198 * per tip: asking again for the same tip returns the one already made.
199 */
200 export async function proposeToParentForge(
201 app: App,
202 actor: User | undefined,
203 repo: Repo,
204 ): Promise<Result<ForgeProposal & { opened: boolean }, ProposeForgeError>> {
205 const writer = requireWriter(app, actor, repo);
206 if (!writer.ok) return writer;
207 const link = await forgeLinkOf(app);
208 const cred = await parentCredential(app);
209 if (link === undefined || cred === undefined || link.repo !== `${repo.ownerName}/${repo.name}`)
210 return err("no_parent_forge");
211
212 const dir = repoDir(app.reposRoot, repo.ownerName, repo.name);
213 const tip = await resolveOid(dir, `refs/heads/${repo.defaultBranch}`);
214 if (tip === undefined || tip === link.tip) return err("nothing_to_propose");
215 const branch = `${repo.defaultBranch.replace(/[^A-Za-z0-9._-]/g, "-")}-${tip.slice(0, 8)}`;
216 const known = app.db
217 .query<Row, [string, string]>("SELECT * FROM upstream_proposal WHERE repo_id = ?1 AND branch = ?2")
218 .get(repo.id, branch);
219 if (known !== null) return ok({ ...asProposal(cred, known), opened: false });
220
221 const landed = mergedPullsForRepos(app.db, [repo.id], 1)[0];
222 const here = app.config.url ?? "";
223 const source =
224 landed === undefined
225 ? `${repo.ownerName}/${repo.name}@${tip.slice(0, 8)}`
226 : `${repo.ownerName}/${repo.name}#${landed.number}`;
227 const title = landed?.title ?? `Changes from ${cred.child}`;
228
229 const head = `child/${cred.child}/${branch}`;
230 const auth = gitAuthEnv(cred.url, basicHeader(cred.child, cred.token));
231 const ref = `refs/heads/${head}`;
outdated

High: leasing "must not exist" means a push that succeeded before a failed pull-open strands this tip for good, because every retry is refused. When the remote ref is already at tip, treat the push as done and go on to open (or find) the pull.

232 const push = (oldOid: string) =>
233 pushToOrigin(dir, `${cred.url}/${cred.repo}.git`, [{ ref, oldOid, newOid: tip }], auth, {
234 timeoutMs: PUSH_TIMEOUT_MS,
235 });
236 let pushed = await push("0".repeat(40));
237 // An earlier press may have landed the branch and then lost the pull: the
238 // branch is at this very tip, so pushing it again expecting it there is a
239 // no-op that succeeds, and the pull opens on this try.
240 if (!pushed.ok) {
241 const again = await push(tip);
242 if (again.ok) pushed = again;
243 }
244 if (!pushed.ok) {
245 const reason = redact(pushed.error.message, ...secretsIn(auth), cred.token).split("\n")[0]!;
246 return err(/could not resolve|unable to access|timed out|refused to connect|connection/i.test(reason)
247 ? { kind: "unreachable" }
248 : { kind: "refused", reason: reason.slice(0, 200) });
249 }
250
251 let res: Response;
252 try {
253 res = await fetch(apiUrl(cred, ""), {
254 method: "POST",
255 redirect: "manual",
256 headers: { authorization: `Bearer ${cred.token}`, "content-type": "application/json" },
257 body: JSON.stringify({
outdated

This path (and the !res.ok refusal after it) leaves the branch pushed at the parent with no local row, which is how the stranded state above comes about.

258 title: title.slice(0, 200),
259 body: `Proposed by child ${cred.child}${here === "" ? "" : ` (${here})`}: ${source}.`,
260 base: repo.defaultBranch,
261 head,
262 }),
263 signal: AbortSignal.timeout(ASK_TIMEOUT_MS),
264 });
265 } catch {
266 return err({ kind: "unreachable" });
267 }
268 if (!res.ok) return err({ kind: "refused", reason: await reasonOf(res, cred) });
269 const made = (await res.json().catch(() => undefined)) as { number?: unknown } | undefined;
270 if (typeof made?.number !== "number" || !Number.isSafeInteger(made.number))
271 return err({ kind: "refused", reason: "the parent's answer was not a pull" });
272
273 const now = Date.now();
274 app.db
275 .query(
276 `INSERT INTO upstream_proposal (repo_id, branch, number, title, source, state, reachable, checked_at, created_at)
277 VALUES (?1, ?2, ?3, ?4, ?5, 'open', 1, ?6, ?6) ON CONFLICT (repo_id, branch) DO NOTHING`,
278 )
279 .run(repo.id, branch, made.number, title.slice(0, 200), source, now);
280 const row = app.db
281 .query<Row, [string, string]>("SELECT * FROM upstream_proposal WHERE repo_id = ?1 AND branch = ?2")
282 .get(repo.id, branch)!;
283 return ok({ ...asProposal(cred, row), opened: true });
284 }
285
286 /** Ask the parent how one pull stands; undefined when it did not say. */
287 async function readState(cred: ParentCredential, number: number): Promise<ForgeProposalState | undefined> {
288 try {
289 const res = await fetch(apiUrl(cred, `/${number}`), {
290 redirect: "manual",
291 headers: { authorization: `Bearer ${cred.token}` },
292 signal: AbortSignal.timeout(ASK_TIMEOUT_MS),
293 });
294 if (!res.ok) return undefined;
295 const d = (await res.json()) as { state?: unknown; mergedAt?: unknown };
296 if (typeof d.mergedAt === "string") return "merged";
297 return d.state === "closed" ? "closed" : d.state === "open" ? "open" : undefined;
298 } catch {
299 return undefined;
300 }
301 }
302
303 /**
304 * What this forge has proposed to its parent forge, newest first, each as
305 * the parent last said. An open one older than the cache is asked again; a
306 * parent that does not answer leaves the last state standing and marks it
307 * unreachable, and is not asked again until the cache runs out.
308 */
309 export async function forgeProposals(
310 app: App,
311 repo: Repo,
312 now: number = Date.now(),
313 /** Whether the parent may be asked: a signed-in viewer's, not an anonymous one's. */
314 refresh = true,
315 ): Promise<ForgeProposal[]> {
316 const cred = await parentCredential(app);
317 if (cred === undefined) return [];
318 const rows = app.db
319 .query<Row, [string]>("SELECT * FROM upstream_proposal WHERE repo_id = ?1 ORDER BY id DESC LIMIT 20")
320 .all(repo.id);
321 const fresh = await Promise.all(
322 rows.map(async (r, i): Promise<Row> => {
323 if (!refresh || i >= REFRESHED || r.state !== "open" || now - r.checked_at < STATE_TTL_MS) return r;
324 const state = await readState(cred, r.number);
325 const next = { ...r, state: state ?? r.state, reachable: state === undefined ? 0 : 1, checked_at: now };
326 app.db
327 .query("UPDATE upstream_proposal SET state = ?1, reachable = ?2, checked_at = ?3 WHERE id = ?4")
328 .run(next.state, next.reachable, now, r.id);
329 return next;
330 }),
331 );
332 return fresh.map((r) => asProposal(cred, r));
333 }
334
335 /** The one line that says how a proposal stands. */
336 export const forgeProposalLine = (p: ForgeProposal): string =>
337 `#${p.number} ${p.title}: ${p.reachable ? p.state : `${p.state}, parent unreachable`}`;
src/core/migrations/20261016170000-upstream-proposals.ts added +18
@@ -0,0 +1,18 @@
1 // What a child forge has proposed to the forge above it: the branch it
2 // pushed there, the pull that opened, and the last state read back from the
3 // parent, with when. The parent is the truth; this is the child's cache of it.
4 export default `
5 CREATE TABLE upstream_proposal (
6 id INTEGER PRIMARY KEY AUTOINCREMENT,
7 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
8 branch TEXT NOT NULL,
9 number INTEGER NOT NULL,
10 title TEXT NOT NULL,
11 source TEXT NOT NULL,
12 state TEXT NOT NULL DEFAULT 'open',
13 reachable INTEGER NOT NULL DEFAULT 1,
14 checked_at INTEGER NOT NULL,
15 created_at INTEGER NOT NULL,
16 UNIQUE (repo_id, branch)
17 );
18 `;
src/http/api.ts +33 −1
@@ -57,7 +57,7 @@ import { familyView } from "../spawn/family.ts";
5757 import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts";
5858 import { reissueDoor, spawnDoor } from "../spawn/door.ts";
5959 import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts";
60 import { proposeUpstream } from "../actions/upstream.ts";
60 import { forgeProposals, proposeToParentForge, proposeUpstream } from "../actions/upstream.ts";
6161 import { severForge, takeOrTell } from "../actions/follow.ts";
6262 import { relationOf } from "../actions/forks.ts";
6363 import { chainOfRepo } from "../spawn/chain.ts";
@@ -2149,6 +2149,38 @@ export function apiRoutes(router: Router<Handler>): void {
21492149 },
21502150 );
21512151
2152 // Across forges: push this forge's change to the parent forge and open the
2153 // pull there; GET says how each proposal stands, as the parent last said.
2154 router.add(
2155 "POST",
2156 "/api/v1/repos/:owner/:name/following/propose-parent",

No test reaches this route. Add one that covers 201 on the first press, 200 on a repeat, 403 for a reader's token, and 502 when the parent is down, so a wrong mapping fails the suite.

2157 async (ctx) => {
2158 const denied = requireScope(ctx, "rw");
2159 if (denied) return denied;
2160 const repo = loadRepo(ctx);
2161 if (!repo) return notFound();
2162 const made = await proposeToParentForge(ctx.app, ctx.auth.user, repo);
2163 if (!made.ok) {
2164 const e = made.error;
2165 if (e === "no_parent_forge")
2166 return apiError("no_parent_forge", 404, "this repository has no parent forge to propose to");
2167 if (e === "nothing_to_propose")
2168 return apiError("nothing_to_propose", 409, "nothing here is ahead of the parent forge");
2169 if (typeof e === "string") return respondAccess(e, 403);
2170 return e.kind === "unreachable"
2171 ? apiError("parent_unreachable", 502, "parent unreachable")
2172 : apiError("refused", 409, e.reason);
2173 }
2174 return json(made.value, made.value.opened ? 201 : 200);
2175 },
2176 );
2177
2178 router.add("GET", "/api/v1/repos/:owner/:name/following/proposals", async (ctx) => {
2179 const repo = loadRepo(ctx);
2180 if (!repo) return notFound();
2181 return json({ items: await forgeProposals(ctx.app, repo, Date.now(), ctx.auth.user !== undefined) });
2182 });
2183
21522184 router.add(
21532185 "POST",
21542186 "/api/v1/repos/:owner/:name/following/converge",
src/http/proposal_gate.ts +19 −10
@@ -1,9 +1,9 @@
11 /**
22 * The one gate every proposal-token request passes, before any handler.
33 *
4 * A proposal token (`core/proposal_token.ts`) may do three things at the
4 * A proposal token (`core/proposal_token.ts`) may do four things at the
55 * parent repository it was minted for and nothing else: advertise
6 * receive-pack, run receive-pack, and open a pull. Everything is refused
6 * receive-pack, run receive-pack, open a pull, and read back one it opened. Everything is refused
77 * unless it matches one of those exactly, so a route added tomorrow is
88 * closed to these tokens until someone writes it in here. Which refs the
99 * push may touch is decided at the ref update (`handleHook`), where the
@@ -11,9 +11,10 @@
1111 */
1212
1313 import type { App } from "../app.ts";
14 import { getPull } from "../core/pulls.ts";
1415 import { getRepoById } from "../core/repos.ts";
1516 import { RateLimiter } from "../core/ratelimit.ts";
16 import { PROPOSAL_BODY_MAX } from "../core/proposal_token.ts";
17 import { PROPOSAL_BODY_MAX, pullProposalOf } from "../core/proposal_token.ts";
1718 import type { Auth } from "./context.ts";
1819 import { apiError } from "./respond.ts";
1920
@@ -52,6 +53,21 @@ export function proposalGate(
5253 const proposal = auth.proposal;
5354 if (proposal === undefined) return undefined;
5455
56 const parent = getRepoById(app.db, proposal.repoId);
57 if (parent === undefined) return refuse();
58 const at = `/${parent.ownerName}/${parent.name}.git`;
59 const pulls = `/api/v1/repos/${parent.ownerName}/${parent.name}/pulls`;
60 const path = url.pathname;
61 const method = request.method;
62
63 // A child may read back the state of a pull it opened, and no other.
64 const tail = method === "GET" && path.startsWith(`${pulls}/`) ? path.slice(pulls.length + 1) : "";
65 const own = /^[1-9][0-9]{0,8}$/.test(tail) ? Number(tail) : undefined;
66 if (own !== undefined) {
67 const pull = getPull(app.db, parent.id, own);
68 if (pull !== undefined && pullProposalOf(app.db, pull.id)?.child === proposal.child)
69 return undefined; // reading back spends none of the minute a press needs
70 }
5571 const verdict = proposalLimiter(app).take(proposal.tokenId, "rw");
5672 if (!verdict.allowed)
5773 return apiError(
@@ -60,13 +76,6 @@ export function proposalGate(
6076 `too many proposals: ${verdict.limit} per minute per token — retry in ${verdict.retryAfter}s`,
6177 );
6278
63 const parent = getRepoById(app.db, proposal.repoId);
64 if (parent === undefined) return refuse();
65 const at = `/${parent.ownerName}/${parent.name}.git`;
66 const pulls = `/api/v1/repos/${parent.ownerName}/${parent.name}/pulls`;
67 const path = url.pathname;
68 const method = request.method;
69
7079 if (
7180 method === "GET" &&
7281 path === `${at}/info/refs` &&
src/http/server.ts +2 −1
@@ -116,7 +116,7 @@ import {
116116 signinPasskey,
117117 } from "../web/pages/passkeys.ts";
118118 import { confirmPage, searchPage } from "../web/pages/search.ts";
119 import { chainPage, severSubmit } from "../web/pages/chain.ts";
119 import { chainPage, proposeParentSubmit, severSubmit } from "../web/pages/chain.ts";
120120 import { followingSubmit } from "../web/pages/following.ts";
121121 import { reconnectSubmit } from "../web/pages/frozen.ts";
122122 import { activityAtom } from "../web/feeds.ts";
@@ -1220,6 +1220,7 @@ export function buildRouter(): Router<Handler> {
12201220 router.add("POST", "/:owner/:name/following", followingSubmit);
12211221 router.add("GET", "/:owner/:name/following", chainPage);
12221222 router.add("POST", "/:owner/:name/following/sever", severSubmit);
1223 router.add("POST", "/:owner/:name/following/propose", proposeParentSubmit);
12231224 // A mirror pulls on demand; a pin says "keep this on my machine". Both are
12241225 // ordinary repo-scoped writes, not account-level ones.
12251226 router.add("POST", "/:owner/:name/sync", repoSyncSubmit);
src/spawn/parent_credential.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * What a child forge holds to speak to its parent forge as a proposer: the
3 * parent's address (`upstream` in `parent.json`), the parent's repository
4 * for this forge's code, and the proposal token with the child name it was
5 * minted for (`core/proposal_token.ts`). The token lives beside the heartbeat
6 * key in `parent.json`, never in the database, never in an answer.
7 */
8
9 import { rename } from "node:fs/promises";
10 import { join } from "node:path";
11 import type { App } from "../app.ts";
12 import { isChildName } from "../core/proposal_token.ts";
13 import { err, ok, type Result } from "../kernel/result.ts";
14 import { forgeLinkOf, originOf } from "./chain.ts";
15
16 export interface ParentCredential {
17 /** The parent forge's origin, no trailing slash. */
18 readonly url: string;
19 /** `owner/name` of the repository on the parent. */
20 readonly repo: string;
21 readonly child: string;
22 readonly token: string;
23 }
24
25 const path = (app: App): string => join(app.config.dataDir, "parent.json");
26
27 async function readDoc(app: App): Promise<Record<string, unknown> | undefined> {
28 try {
29 const doc = await Bun.file(path(app)).json();
30 return typeof doc === "object" && doc !== null && !Array.isArray(doc)
31 ? (doc as Record<string, unknown>)
32 : undefined;
33 } catch {
34 return undefined;
35 }
36 }
37
38 /** The credential, when this forge follows a parent and was handed one. */
39 export async function parentCredential(app: App): Promise<ParentCredential | undefined> {
40 const doc = await readDoc(app);
41 const link = await forgeLinkOf(app);
42 if (doc === undefined || link === undefined || link.severedAt !== undefined) return undefined;
43 const url = originOf(doc["upstream"]);
44 const p = doc["proposal"];
45 if (url === "" || typeof p !== "object" || p === null) return undefined;
46 const { child, token } = p as Record<string, unknown>;
47 if (typeof child !== "string" || !isChildName(child)) return undefined;
48 if (typeof token !== "string" || !/^[\x21-\x7e]{8,200}$/.test(token)) return undefined;
49 return { url, repo: link.upstream, child, token };
50 }
51
52 /** Keep the token a parent minted for this child. The rest of `parent.json` stays as it is. */
53 export async function keepParentCredential(

Only the test calls this. Either wire it to the token-handover door when that lands, or move it into the test helper so production carries no dead export.

54 app: App,
55 input: { child: string; token: string },
56 ): Promise<Result<void, "invalid" | "no_parent">> {
57 if (!isChildName(input.child) || !/^[\x21-\x7e]{8,200}$/.test(input.token)) return err("invalid");
58 const doc = await readDoc(app);
59 if (doc === undefined) return err("no_parent");
60 await Bun.write(
61 `${path(app)}.tmp`,
62 JSON.stringify({ ...doc, proposal: { child: input.child, token: input.token } }),
63 { mode: 0o600 },
64 );
65 await rename(`${path(app)}.tmp`, path(app));
66 return ok();
67 }
src/tui/screens/chain.ts +39 −5
@@ -6,7 +6,9 @@
66
77 import type { App as Forge } from "../../app.ts";
88 import { severForge } from "../../actions/follow.ts";
9 import { canAdmin, canRead, getRepo, type Repo } from "../../core/repos.ts";
9 import { forgeProposalLine, forgeProposals, proposeToParentForge } from "../../actions/upstream.ts";
10 import { parentCredential } from "../../spawn/parent_credential.ts";
11 import { canAdmin, canRead, canWrite, getRepo, type Repo } from "../../core/repos.ts";
1012 import type { User } from "../../core/users.ts";
1113 import { chainLines, chainOfRepo, forgeLinkOf, type ChainView } from "../../spawn/chain.ts";
1214 import type { Ctx, Screen } from "../app/app.ts";
@@ -33,12 +35,26 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
3335 const found = await readableChain(app, user);
3436 if (found === undefined) return familyScreen(ctx, [["chain", "this forge follows nothing across forges"]], "Chain");
3537 const { repo, view } = found;
36 const rows = chainLines(view);
3738 const owner = user !== undefined && view.severedAt === null && canAdmin(app.db, repo, user);
38 if (!owner) return familyScreen(ctx, rows, "Chain");
39 const writer = user !== undefined && view.severedAt === null && canWrite(app.db, repo, user);
40 const proposing = view.severedAt === null && (await parentCredential(app)) !== undefined;
41 const linesOf = async (): Promise<[string, string][]> => [
42 ...chainLines(view),
43 ...(proposing
44 ? (await forgeProposals(app, repo, Date.now(), user !== undefined)).map(
45 (p): [string, string] => ["proposal", forgeProposalLine(p)],
46 )
47 : []),
48 ];
49 let rows = await linesOf();
50 if (!owner && !writer) return familyScreen(ctx, rows, "Chain");
3951 const self: Screen = {
4052 title: "Chain",
41 keys: [binding(["s"], "sever"), binding(["esc"], "close")],
53 keys: [
54 ...(writer && proposing ? [binding(["p"], "propose")] : []),
55 ...(owner ? [binding(["s"], "sever")] : []),
56 binding(["esc"], "close"),
57 ],
4258 render(area, s) {
4359 const c = ctx.theme.c;
4460 const w = Math.min(78, Math.max(30, area.w - 6));
@@ -49,7 +65,25 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx):
4965 },
5066 key(e) {
5167 if (e.key === "esc" || e.key === "q") ctx.pop();
52 else if (e.key === "s")
68 else if (e.key === "p" && writer && proposing)
69 void proposeToParentForge(app, user, repo).then(async (made) => {
70 if (!made.ok) {
71 const err = made.error;
72 return ctx.toast(
73 err === "nothing_to_propose"
74 ? "Nothing here is ahead of the parent forge"
75 : typeof err === "string"
76 ? `Not proposed: ${err}`
77 : err.kind === "unreachable"
78 ? "Parent unreachable: nothing was proposed"
79 : `The parent forge did not take it: ${err.reason}`,
80 "danger",
81 );
82 }
83 rows = await linesOf();
84 ctx.toast(`Proposed to the parent forge as #${made.value.number}`, "success");
85 });
86 else if (e.key === "s" && owner)
5387 ctx.push(
5488 promptScreen(ctx, self, {
5589 title: "Sever from the parent forge",
src/web/pages/chain.ts +68 −1
@@ -6,9 +6,11 @@
66 */
77
88 import { severForge } from "../../actions/follow.ts";
9 import { canAdmin } from "../../core/repos.ts";
9 import { forgeProposals, proposeToParentForge, type ForgeProposal } from "../../actions/upstream.ts";
10 import { canAdmin, canWrite } from "../../core/repos.ts";
1011 import type { Ctx } from "../../http/context.ts";
1112 import { chainView, forgeLinkOf, type ChainLine } from "../../spawn/chain.ts";
13 import { parentCredential } from "../../spawn/parent_credential.ts";
1214 import { loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts";
1315 import { field, readForm } from "../form.ts";
1416 import { html, type Html } from "../html.ts";
@@ -47,6 +49,35 @@ async function carriesChain(ctx: Ctx): Promise<boolean> {
4749 return repo !== undefined && link !== undefined && link.repo === `${repo.ownerName}/${repo.name}`;
4850 }
4951
52 const STATE_WORDS = { open: "open", merged: "merged", closed: "closed" } as const;
53
54 function proposalCard(base: string, proposals: readonly ForgeProposal[], writer: boolean): Html {
55 return card(
56 html`${proposals.length === 0
57 ? html`<p class="muted">Nothing proposed to the parent forge yet.</p>`
58 : html`<ul class="list" aria-label="Proposals to the parent forge">
59 ${proposals.map(
60 (p) => html`<li>
61 <div class="row">
62 <span class="row-main">
63 <a class="row-title" href="${p.url}" rel="noopener">#${p.number} ${p.title}</a>
64 <span class="row-meta"
65 >${STATE_WORDS[p.state]}${p.reachable ? null : html` · parent unreachable`}</span
66 >
67 </span>
68 </div>
69 </li>`,
70 )}
71 </ul>`}
72 ${writer
73 ? html`<form method="post" action="${base}/following/propose" class="mt">
74 <div class="form-actions">${btn("Propose to the parent forge", { type: "submit", icon: "merge" })}</div>
75 </form>`
76 : null}`,
77 { head: html`<h2>Proposals</h2>` },
78 );
79 }
80
5081 // GET /:owner/:name/following
5182 export async function chainPage(ctx: Ctx): Promise<Response> {
5283 const repo = loadRepo(ctx);
@@ -56,6 +87,9 @@ export async function chainPage(ctx: Ctx): Promise<Response> {
5687 if (view === undefined) return redirect(`${repoBase(repo)}#following`);
5788 const base = repoBase(repo);
5889 const owner = canAdmin(ctx.app.db, repo, ctx.auth.user);
90 const proposing = view.severedAt === null && (await parentCredential(ctx.app)) !== undefined;
91 const proposals = proposing ? await forgeProposals(ctx.app, repo, Date.now(), ctx.auth.user !== undefined) : [];
92 const writer = canWrite(ctx.app.db, repo, ctx.auth.user);
5993 return page({
6094 ctx,
6195 title: "Following",
@@ -69,6 +103,7 @@ export async function chainPage(ctx: Ctx): Promise<Response> {
69103 </ul>`,
70104 { pad: false },
71105 )}
106 ${proposing ? proposalCard(base, proposals, writer) : null}
72107 ${view.severedAt !== null
73108 ? html`<p class="muted" id="severed">
74109 This forge follows nothing since ${view.severedAt.slice(0, 10)}. Its code and history are
@@ -113,3 +148,35 @@ export async function severSubmit(ctx: Ctx): Promise<Response> {
113148 });
114149 return flashOrJson(ctx, to, { message: `Severed: following nothing since ${cut.value.slice(0, 10)}. Nothing was deleted.` });
115150 }
151
152 /** POST /:owner/:name/following/propose: push to the parent forge and open the pull there; a writer only. */
153 export async function proposeParentSubmit(ctx: Ctx): Promise<Response> {

The page button's handler is untested. Add a form POST from a signed-in writer that follows the redirect and checks the flash, plus one without a same-origin header that must be refused.

154 const repo = loadRepo(ctx);
155 if (!repo) return notFoundPage(ctx);
156 if (!ctx.auth.user) return signInFirst(ctx);
157 const form = await readForm(ctx, "rw");
158 if (form instanceof Response) return form;
159 const to = `${repoBase(repo)}/following`;
160 const made = await proposeToParentForge(ctx.app, ctx.auth.user, repo);
161 if (!made.ok) {
162 const e = made.error;
163 return flashOrJson(ctx, to, {
164 tone: "danger",
165 message:
166 e === "no_parent_forge"
167 ? "This forge has no parent forge to propose to."
168 : e === "nothing_to_propose"
169 ? "Nothing here is ahead of the parent forge."
170 : typeof e === "string"
171 ? "You need write access to propose from here."
172 : e.kind === "unreachable"
173 ? "Parent unreachable: nothing was proposed."
174 : `The parent forge did not take the proposal: ${e.reason}.`,
175 });
176 }
177 return flashOrJson(ctx, to, {
178 message: made.value.opened
179 ? `Proposed to the parent forge as #${made.value.number}.`
180 : `Already proposed to the parent forge as #${made.value.number}.`,
181 });
182 }
test/upstream-forge.test.ts added +364
@@ -0,0 +1,364 @@
1 /**
2 * Propose upstream across forges (#1832): a child forge pushes its change to
3 * `child/<name>/…` at its parent forge with the proposal token and opens the
4 * pull there; the child shows the proposal as the parent last said it, and
5 * a parent that does not answer reads as one line, never an error page.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
13 import { mergePull, openPull } from "../src/actions/pulls.ts";
14 import {
15 forgeProposals,
16 proposeToParentForge,
17 STATE_TTL_MS,
18 } from "../src/actions/upstream.ts";
19 import { createApp, type App } from "../src/app.ts";
20 import { mintProposalToken } from "../src/core/proposal_token.ts";
21 import { getPull } from "../src/core/pulls.ts";
22 import { getRepo, type Repo } from "../src/core/repos.ts";
23 import { createSession } from "../src/core/sessions.ts";
24 import { createToken } from "../src/core/tokens.ts";
25 import { createUser, type User } from "../src/core/users.ts";
26 import { runGit } from "../src/git/env.ts";
27 import { repoDir } from "../src/git/repos.ts";
28 import { proposalLimiter } from "../src/http/proposal_gate.ts";
29 import { serve, type Servers } from "../src/http/server.ts";
30 import { chainScreen } from "../src/tui/screens/chain.ts";
31 import { keepParentCredential } from "../src/spawn/parent_credential.ts";
32 import { quietLogs } from "./helpers/globals.ts";
33
34 quietLogs();
35
36 const PASSWORD = "a-long-upstream-passphrase";
37
38 describe("a child forge proposes to its parent forge", () => {
39 let dir = "";
40 let parent: App;
41 let child: App;
42 let parentServer: Servers;
43 let childServer: Servers;
44 let alice: User;
45 let kid: User;
46 let lib: Repo;
47 let forge: Repo;
48 let secret = "";
49
50 const person = async (app: App, username: string) => {
51 const made = await createUser(app.db, {
52 trusted: true,
53 username,
54 email: `${username}@forge.local`,
55 password: PASSWORD,
56 adminIfFirst: true,
57 });
58 if (!made.ok) throw new Error(made.error);
59 return made.value;
60 };
61
62 /** A change landing on the child: a branch, a pull, a merge. */
63 const land = async (branch: string, title: string) => {
64 const c = await commitToBranch(child, kid, forge, {
65 branch,
66 base: "main",
67 message: title,
68 files: [{ path: `${branch}.md`, content: `${title}\n` }],
69 });
70 if (!c.ok) throw new Error(JSON.stringify(c.error));
71 const opened = await openPull(child, kid, forge, {
72 title,
73 baseBranch: "main",
74 headBranch: branch,
75 });
76 if (!opened.ok) throw new Error(JSON.stringify(opened.error));
77 const merged = await mergePull(child, kid, forge, opened.value, "merge");
78 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
79 return opened.value;
80 };
81
82 const forgeCount = (): number =>
83 child.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM upstream_proposal").get()!.n;
84 const parentPull = (number: number) => getPull(parent.db, lib.id, number)!;
85
86 beforeAll(async () => {
87 dir = await mkdtemp(join(tmpdir(), "forge-upstream-"));
88 parent = createApp({ dataDir: join(dir, "parent"), port: 0, host: "parent.test" });
89 parentServer = await serve(parent);
90 alice = await person(parent, "alice");
91 const made = await createRepo(parent, alice, { name: "lib", visibility: "public" });
92 if (!made.ok) throw new Error(String(made.error));
93 lib = made.value;
94 const seeded = await commitToBranch(parent, alice, lib, {
95 branch: "main",
96 message: "Start",
97 files: [{ path: "README.md", content: "hi\n" }],
98 });
99 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
100 const minted = mintProposalToken(parent.db, alice.id, lib.id, {
101 child: "kid",
102 origin: "https://kid.example.com/",
103 });
104 if (!minted.ok) throw new Error(minted.error);
105 secret = minted.value.token;
106 // Many presses in one minute are the test's doing, not a flood.
107 proposalLimiter(parent).setLimits({ perMinute: 1000, adminPerMinute: 1000, enabled: true });
108
109 child = createApp({
110 dataDir: join(dir, "child"),
111 port: 0,
112 host: "kid.test",
113 url: "https://kid.example.com",
114 });
115 childServer = await serve(child);
116 kid = await person(child, "kid");
117 const f = await createRepo(child, kid, { name: "forge", visibility: "public" });
118 if (!f.ok) throw new Error(String(f.error));
119 // The child's code is a copy of the parent's: the same history.
120 const there = repoDir(child.reposRoot, "kid", "forge");
121 await rm(there, { recursive: true, force: true });
122 const cloned = await runGit(
123 ["clone", "--bare", repoDir(parent.reposRoot, "alice", "lib"), there],
124 {},
125 );
126 if (!cloned.ok) throw new Error(String(cloned.error));
127 forge = getRepo(child.db, "kid", "forge")!;
128 const tip = await runGit(["rev-parse", "main"], { cwd: there });
129 if (!tip.ok) throw new Error(String(tip.error));
130 await Bun.write(
131 join(child.config.dataDir, "parent.json"),
132 JSON.stringify({
133 upstream: parentServer.url,
134 name: "kid",
135 forgeCode: {
136 repo: "kid/forge",
137 upstream: "alice/lib",
138 mode: "tell-me",
139 tip: tip.value.trim(),
140 chain: [],
141 },
142 }),
143 );
144 const kept = await keepParentCredential(child, { child: "kid", token: secret });
145 if (!kept.ok) throw new Error(kept.error);
146 });
147
148 afterAll(async () => {
149 await childServer.stop();
150 await parentServer.stop();
151 await rm(dir, { recursive: true, force: true });
152 });
153
154 test("with nothing ahead of the parent there is nothing to propose", async () => {
155 const none = await proposeToParentForge(child, kid, forge);
156 expect(none.ok ? "ok" : none.error).toBe("nothing_to_propose");
157 });
158
159 test("one press pushes to child/<name>/… and opens the pull, naming the change", async () => {
160 const landed = await land("feat", "Add feat");
161 const made = await proposeToParentForge(child, kid, forge);
162 if (!made.ok) throw new Error(JSON.stringify(made.error));
163 expect(made.value.opened).toBe(true);
164 expect(made.value.state).toBe("open");
165 expect(made.value.branch).toMatch(/^main-[0-9a-f]{8}$/);
166
167 const pull = parentPull(made.value.number);
168 expect(pull.title).toBe("Add feat");
169 expect(pull.headBranch).toBe(`child/kid/${made.value.branch}`);
170 expect(pull.body).toContain(`kid/forge#${landed.number}`);
171 expect(pull.body).toContain("Proposed by child kid");
172
173 // Asking again for the same tip does not double it.
174 const again = await proposeToParentForge(child, kid, forge);
175 if (!again.ok) throw new Error(JSON.stringify(again.error));
176 expect(again.value.opened).toBe(false);
177 expect(again.value.number).toBe(made.value.number);
178 });
179
180 test("a reader of the child cannot propose", async () => {
181 const stranger = await createUser(child.db, {
182 trusted: true,
183 username: "stranger",
184 email: "[email protected]",
185 password: PASSWORD,
186 });
187 if (!stranger.ok) throw new Error(stranger.error);
188 const refused = await proposeToParentForge(child, stranger.value, forge);
189 expect(refused.ok ? "ok" : refused.error).toBe("forbidden");
190 });
191
192 test("the state is read from the parent, cached, and follows a merge", async () => {
193 const t0 = Date.now();
194 const [first] = await forgeProposals(child, forge, t0);
195 expect(first!.state).toBe("open");
196 expect(first!.reachable).toBe(true);
197 expect(first!.url).toBe(`${parentServer.url}/alice/lib/pulls/${first!.number}`);
198
199 // The parent merges; inside the cache window the child still says open.
200 const merged = await mergePull(parent, alice, lib, parentPull(first!.number), "merge");
201 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
202 expect((await forgeProposals(child, forge, t0 + 1000))[0]!.state).toBe("open");
203 expect((await forgeProposals(child, forge, t0 + STATE_TTL_MS + 1))[0]!.state).toBe("merged");
204 });
205
206 test("a proposal token reads back its own pulls and no other", async () => {
207 const own = await fetch(`${parentServer.url}/api/v1/repos/alice/lib/pulls/1`, {
208 headers: { authorization: `Bearer ${secret}` },
209 });
210 expect(own.status).toBe(200);
211 // A pull a person opened is not the kid's to read.
212 const c = await commitToBranch(parent, alice, lib, {
213 branch: "other",
214 base: "main",
215 message: "Other",
216 files: [{ path: "other.md", content: "x\n" }],
217 });
218 if (!c.ok) throw new Error(JSON.stringify(c.error));
219 const theirs = await openPull(parent, alice, lib, {
220 title: "Not the kid's",
221 baseBranch: "main",
222 headBranch: "other",
223 });
224 if (!theirs.ok) throw new Error(JSON.stringify(theirs.error));
225 for (const path of [`/pulls/${theirs.value.number}`, "/pulls/999", "/pulls", "/pulls/0"]) {
226 const res = await fetch(`${parentServer.url}/api/v1/repos/alice/lib${path}`, {
227 headers: { authorization: `Bearer ${secret}` },
228 });
229 expect([path, res.status]).toEqual([path, 403]);
230 }
231 });
232
233 test("a push that landed but whose pull was lost is retried, not stranded", async () => {
234 await land("lost", "Add lost");
235 const real = globalThis.fetch;
236 globalThis.fetch = Object.assign(
237 async (input: any, init?: any) =>
238 String(input).endsWith("/alice/lib/pulls") && init?.method === "POST"
239 ? new Response("{}", { status: 500 })
240 : real(input, init),
241 { preconnect: real.preconnect },
242 ) as typeof fetch;
243 try {
244 const first = await proposeToParentForge(child, kid, forge);
245 expect(first.ok).toBe(false);
246 } finally {
247 globalThis.fetch = real;
248 }
249 const second = await proposeToParentForge(child, kid, forge);
250 if (!second.ok) throw new Error(JSON.stringify(second.error));
251 expect(second.value.opened).toBe(true);
252 expect(parentPull(second.value.number).title).toBe("Add lost");
253 });
254
255 test("the terminal lists the proposals and `p` proposes", async () => {
256 await land("termed", "Add termed");
257 const toasts: string[] = [];
258 const ctx = { toast: (m: string) => toasts.push(m), pop() {}, push() {}, theme: {} } as any;
259 const screen = await chainScreen(child, kid, ctx);
260 expect(screen.keys.map((k: any) => k.keys?.[0] ?? k[0])).toContain("p");
261 screen.key({ key: "p" } as any);
262 await Bun.sleep(1500);

A fixed 1.5 s sleep is slow, and it will be flaky on a busy check machine. Poll until the toast appears (with a deadline), or have key expose the promise so the test can await it.

263 expect(toasts.join("\n")).toContain("Proposed to the parent forge as #");
264 });
265
266 test("the page button: a writer's form POST proposes; a cross-origin one is refused", async () => {
267 await land("viaform", "Add viaform");
268 const cookie = `forge_session=${createSession(child.db, kid.id).token}`;
269 const post = (headers: Record<string, string>) =>
270 fetch(`${childServer.url}/kid/forge/following/propose`, {
271 method: "POST",
272 redirect: "manual",
273 headers: { cookie, ...headers },
274 });
275 const before = forgeCount();
276 const evil = await post({ origin: "https://evil.example" });
277 expect(evil.status).toBe(403);
278 expect(forgeCount()).toBe(before);
279
280 const sent = await post({ origin: "https://kid.example.com" });
281 expect([302, 303]).toContain(sent.status);
282 expect(new URL(sent.headers.get("location")!, childServer.url).pathname).toBe("/kid/forge/following");
283 expect(forgeCount()).toBe(before + 1);
284
285 // The same press, asking for the flash as JSON, says what happened.
286 const again = await post({ origin: "https://kid.example.com", accept: "application/json" });
287 const said = (await again.json()) as { ok: boolean; flash: string };
288 expect(said.flash).toMatch(/^Already proposed to the parent forge as #\d+\.$/);
289 const page = await fetch(`${childServer.url}/kid/forge/following`);
290 expect(await page.text()).toContain("Add viaform");
291 });
292
293 test("the API door: 201 then 200, 403 for a reader", async () => {
294 await land("viaapi", "Add viaapi");
295 const writer = createToken(child.db, kid.id, "api", "rw").token;
296 const reader = await createUser(child.db, {
297 trusted: true,
298 username: "reader",
299 email: "[email protected]",
300 password: PASSWORD,
301 });
302 if (!reader.ok) throw new Error(reader.error);
303 const readerToken = createToken(child.db, reader.value.id, "r", "rw").token;
304 const press = (token: string) =>
305 fetch(`${childServer.url}/api/v1/repos/kid/forge/following/propose-parent`, {
306 method: "POST",
307 headers: { authorization: `Bearer ${token}` },
308 });
309 expect((await press(readerToken)).status).toBe(403);
310 const first = await press(writer);
311 expect(first.status).toBe(201);
312 const made = (await first.json()) as { number: number; opened: boolean; state: string };
313 expect(made.opened).toBe(true);
314 const second = await press(writer);
315 expect(second.status).toBe(200);
316 expect(((await second.json()) as { number: number }).number).toBe(made.number);
317 });
318
319 test("reading back a pull spends none of the token's minute", async () => {
320 proposalLimiter(parent).setLimits({ perMinute: 2, adminPerMinute: 2, enabled: true });
321 try {
322 for (let i = 0; i < 8; i++) {
323 const res = await fetch(`${parentServer.url}/api/v1/repos/alice/lib/pulls/1`, {
324 headers: { authorization: `Bearer ${secret}` },
325 });
326 expect(res.status).toBe(200);
327 }
328 } finally {
329 proposalLimiter(parent).setLimits({ perMinute: 1000, adminPerMinute: 1000, enabled: true });
330 }
331 });
332
333 test("an unreachable parent degrades to one line, not an error page", async () => {
334 await land("second", "Add second");
335 const made = await proposeToParentForge(child, kid, forge);
336 if (!made.ok) throw new Error(JSON.stringify(made.error));
337 expect(made.value.state).toBe("open");
338
339 await parentServer.stop();
340 const later = Date.now() + 10 * STATE_TTL_MS;
341 const shown = await forgeProposals(child, forge, later);
342 const second = shown.find((p) => p.number === made.value.number)!;
343 expect(second.state).toBe("open");
344 expect(second.reachable).toBe(false);
345 // The merged one is final and is not asked about again.
346 expect(shown.some((p) => p.state === "merged" && p.reachable)).toBe(true);
347
348 const listed = await fetch(`${childServer.url}/api/v1/repos/kid/forge/following/proposals`);
349 expect(listed.status).toBe(200);
350 const page = await fetch(`${childServer.url}/kid/forge/following`);
351 expect(page.status).toBe(200);
352 expect(await page.text()).toContain("parent unreachable");
353
354 await land("third", "Add third");
355 const refused = await proposeToParentForge(child, kid, forge);
356 expect(refused.ok ? "ok" : refused.error).toEqual({ kind: "unreachable" });
357 const api = await fetch(`${childServer.url}/api/v1/repos/kid/forge/following/propose-parent`, {
358 method: "POST",
359 headers: { authorization: `Bearer ${createToken(child.db, kid.id, "down", "rw").token}` },
360 });
361 expect(api.status).toBe(502);
362 expect(((await api.json()) as { error: { code?: string } | string }).error).toBeDefined();
363 });
364 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs