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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1832-propose-upstream-across-forges-part-2-fr → main 4 commits fork 8 threads · 8 unresolved
README.md +18
| @@ -104,6 +104,24 @@ opened with it says "proposed by child … from …" on every tab of the pull's | ||
| 104 | 104 | page, in the terminal's pull view, and as `proposal` in the pull's JSON |
| 105 | 105 | (`GET …/pulls/:number`, and MCP's `pull` tool). |
| 106 | 106 | |
| 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 | |
| 123 | the token into `parent.json` (part 2 of #2182 covers minting and handing over). | |
| 124 | ||
| 107 | 125 | Minting, listing and revoking go through one action |
| 108 | 126 | (`src/actions/proposal_tokens.ts`) at every door, and only a writer of the |
| 109 | 127 | 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 { | ||
| 16 | 16 | } from "../core/repos.ts"; |
| 17 | 17 | import { listPulls, mergedPullsForRepos, type Pull } from "../core/pulls.ts"; |
| 18 | 18 | import type { User } from "../core/users.ts"; |
| 19 | import { aheadOids } from "../git/merge.ts"; | |
| 19 | import { aheadOids, resolveOid } from "../git/merge.ts"; | |
| 20 | 20 | 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"; | |
| 21 | 25 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 22 | 26 | import { requireWriter, type AccessError } from "./access.ts"; |
| 23 | 27 | import { openPull } from "./pulls.ts"; |
| @@ -120,3 +124,214 @@ export async function proposeUpstream( | ||
| 120 | 124 | }); |
| 121 | 125 | return ok({ pull: opened.value, opened: true, changes: landed.length }); |
| 122 | 126 | } |
| 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 |
||
| 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 |
||
| 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"; | ||
| 57 | 57 | import { receiveBeat, SIGNATURE_HEADER } from "../spawn/heartbeat.ts"; |
| 58 | 58 | import { reissueDoor, spawnDoor } from "../spawn/door.ts"; |
| 59 | 59 | import { listSecrets, putSecret, removeSecret } from "../actions/secrets.ts"; |
| 60 | import { proposeUpstream } from "../actions/upstream.ts"; | |
| 60 | import { forgeProposals, proposeToParentForge, proposeUpstream } from "../actions/upstream.ts"; | |
| 61 | 61 | import { severForge, takeOrTell } from "../actions/follow.ts"; |
| 62 | 62 | import { relationOf } from "../actions/forks.ts"; |
| 63 | 63 | import { chainOfRepo } from "../spawn/chain.ts"; |
| @@ -2149,6 +2149,38 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2149 | 2149 | }, |
| 2150 | 2150 | ); |
| 2151 | 2151 | |
| 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 | ||
| 2152 | 2184 | router.add( |
| 2153 | 2185 | "POST", |
| 2154 | 2186 | "/api/v1/repos/:owner/:name/following/converge", |
src/http/proposal_gate.ts +19 −10
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The one gate every proposal-token request passes, before any handler. |
| 3 | 3 | * |
| 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 | |
| 5 | 5 | * 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 | |
| 7 | 7 | * unless it matches one of those exactly, so a route added tomorrow is |
| 8 | 8 | * closed to these tokens until someone writes it in here. Which refs the |
| 9 | 9 | * push may touch is decided at the ref update (`handleHook`), where the |
| @@ -11,9 +11,10 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import type { App } from "../app.ts"; |
| 14 | import { getPull } from "../core/pulls.ts"; | |
| 14 | 15 | import { getRepoById } from "../core/repos.ts"; |
| 15 | 16 | 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"; | |
| 17 | 18 | import type { Auth } from "./context.ts"; |
| 18 | 19 | import { apiError } from "./respond.ts"; |
| 19 | 20 | |
| @@ -52,6 +53,21 @@ export function proposalGate( | ||
| 52 | 53 | const proposal = auth.proposal; |
| 53 | 54 | if (proposal === undefined) return undefined; |
| 54 | 55 | |
| 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 | } | |
| 55 | 71 | const verdict = proposalLimiter(app).take(proposal.tokenId, "rw"); |
| 56 | 72 | if (!verdict.allowed) |
| 57 | 73 | return apiError( |
| @@ -60,13 +76,6 @@ export function proposalGate( | ||
| 60 | 76 | `too many proposals: ${verdict.limit} per minute per token — retry in ${verdict.retryAfter}s`, |
| 61 | 77 | ); |
| 62 | 78 | |
| 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 | ||
| 70 | 79 | if ( |
| 71 | 80 | method === "GET" && |
| 72 | 81 | path === `${at}/info/refs` && |
src/http/server.ts +2 −1
| @@ -116,7 +116,7 @@ import { | ||
| 116 | 116 | signinPasskey, |
| 117 | 117 | } from "../web/pages/passkeys.ts"; |
| 118 | 118 | 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"; | |
| 120 | 120 | import { followingSubmit } from "../web/pages/following.ts"; |
| 121 | 121 | import { reconnectSubmit } from "../web/pages/frozen.ts"; |
| 122 | 122 | import { activityAtom } from "../web/feeds.ts"; |
| @@ -1220,6 +1220,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1220 | 1220 | router.add("POST", "/:owner/:name/following", followingSubmit); |
| 1221 | 1221 | router.add("GET", "/:owner/:name/following", chainPage); |
| 1222 | 1222 | router.add("POST", "/:owner/:name/following/sever", severSubmit); |
| 1223 | router.add("POST", "/:owner/:name/following/propose", proposeParentSubmit); | |
| 1223 | 1224 | // A mirror pulls on demand; a pin says "keep this on my machine". Both are |
| 1224 | 1225 | // ordinary repo-scoped writes, not account-level ones. |
| 1225 | 1226 | 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 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { App as Forge } from "../../app.ts"; |
| 8 | 8 | 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"; | |
| 10 | 12 | import type { User } from "../../core/users.ts"; |
| 11 | 13 | import { chainLines, chainOfRepo, forgeLinkOf, type ChainView } from "../../spawn/chain.ts"; |
| 12 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -33,12 +35,26 @@ export async function chainScreen(app: Forge, user: User | undefined, ctx: Ctx): | ||
| 33 | 35 | const found = await readableChain(app, user); |
| 34 | 36 | if (found === undefined) return familyScreen(ctx, [["chain", "this forge follows nothing across forges"]], "Chain"); |
| 35 | 37 | const { repo, view } = found; |
| 36 | const rows = chainLines(view); | |
| 37 | 38 | 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"); | |
| 39 | 51 | const self: Screen = { |
| 40 | 52 | 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 | ], | |
| 42 | 58 | render(area, s) { |
| 43 | 59 | const c = ctx.theme.c; |
| 44 | 60 | 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): | ||
| 49 | 65 | }, |
| 50 | 66 | key(e) { |
| 51 | 67 | 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) | |
| 53 | 87 | ctx.push( |
| 54 | 88 | promptScreen(ctx, self, { |
| 55 | 89 | title: "Sever from the parent forge", |
src/web/pages/chain.ts +68 −1
| @@ -6,9 +6,11 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | 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"; | |
| 10 | 11 | import type { Ctx } from "../../http/context.ts"; |
| 11 | 12 | import { chainView, forgeLinkOf, type ChainLine } from "../../spawn/chain.ts"; |
| 13 | import { parentCredential } from "../../spawn/parent_credential.ts"; | |
| 12 | 14 | import { loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts"; |
| 13 | 15 | import { field, readForm } from "../form.ts"; |
| 14 | 16 | import { html, type Html } from "../html.ts"; |
| @@ -47,6 +49,35 @@ async function carriesChain(ctx: Ctx): Promise<boolean> { | ||
| 47 | 49 | return repo !== undefined && link !== undefined && link.repo === `${repo.ownerName}/${repo.name}`; |
| 48 | 50 | } |
| 49 | 51 | |
| 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 | ||
| 50 | 81 | // GET /:owner/:name/following |
| 51 | 82 | export async function chainPage(ctx: Ctx): Promise<Response> { |
| 52 | 83 | const repo = loadRepo(ctx); |
| @@ -56,6 +87,9 @@ export async function chainPage(ctx: Ctx): Promise<Response> { | ||
| 56 | 87 | if (view === undefined) return redirect(`${repoBase(repo)}#following`); |
| 57 | 88 | const base = repoBase(repo); |
| 58 | 89 | 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); | |
| 59 | 93 | return page({ |
| 60 | 94 | ctx, |
| 61 | 95 | title: "Following", |
| @@ -69,6 +103,7 @@ export async function chainPage(ctx: Ctx): Promise<Response> { | ||
| 69 | 103 | </ul>`, |
| 70 | 104 | { pad: false }, |
| 71 | 105 | )} |
| 106 | ${proposing ? proposalCard(base, proposals, writer) : null} | |
| 72 | 107 | ${view.severedAt !== null |
| 73 | 108 | ? html`<p class="muted" id="severed"> |
| 74 | 109 | 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> { | ||
| 113 | 148 | }); |
| 114 | 149 | return flashOrJson(ctx, to, { message: `Severed: following nothing since ${cut.value.slice(0, 10)}. Nothing was deleted.` }); |
| 115 | 150 | } |
| 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 |
||
| 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 | }); |
Leaving out the terminal door is a checklist miss: add propose and the proposals list to
src/tui/screens/chain.tsnext to sever, then remove this line.