// **A merge check's verdict holds for the trunk commit it judged** (novox/hq ADR 0323, issue 341). // // The build seat checks a pull request's head merged into its trunk's head at that moment — the verdict's trunk // commit — and the verdict names both: "head 1a2b3c4d5e6f merged into trunk 7a8b9c0d1e2f", in both statuses' // descriptions. When the trunk moves, every open pull request's verdict into it is out of date: // // - **the forge's holder** (index.ts) hears the move on its own — it reads each trunk's head on every pass that // looks at the repository, never through mesh-delivery (ADR 0239 §36) — and sets mesh/merge-gate and // mesh/repo-check on the head of every open pull request into that trunk back to pending, with the reason. // Nothing is run for it: a recheck, a new head, or the merge tool asks the check again; // - **the merge tool** (tools/index.ts) compares the trunk commit the head's verdict names with the trunk's head at // the moment it is asked, merges nothing when they differ, asks the head's check once — the same announcement a // new head makes — and says how long the last check of that repository took. It never merges by itself after. // // Pure functions and two small files of the module's own state, so this is tested without a forge. import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js"; import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js"; /** A commit as a verdict names it: its first twelve characters. */ export function twelve(sha: string | undefined): string { return String(sha ?? "").slice(0, 12).toLowerCase(); } /** Whether two commits are one, however either is abbreviated (seven characters at least). */ export function sameCommit(a: string | undefined, b: string | undefined): boolean { const x = String(a ?? "").toLowerCase(), y = String(b ?? "").toLowerCase(); if (x.length < 7 || y.length < 7) return false; return x.length <= y.length ? y.startsWith(x) : x.startsWith(y); } /** What a verdict judged, as both statuses' descriptions begin (ADR 0323 §3). */ export function judgedWords(head: string, trunk: string): string { return `head ${twelve(head)} merged into trunk ${twelve(trunk)}`; } /** The trunk commit a merge check's status names: the one its verdict judged ("merged into trunk …"), or, for a * verdict put out of date, the one it had judged ("judged against trunk …"). Undefined when it names none, as a * verdict of a build seat from before ADR 0323 does. */ export function trunkNamed(description: string | undefined): string | undefined { const m = /(?:merged into|judged against) trunk ([0-9a-f]{7,64})\b/i.exec(description ?? ""); return m ? m[1].toLowerCase() : undefined; } /** A verdict out of date, as its statuses say it (ADR 0323 §4). */ export function outOfDateWords(judged: string | undefined, now: string): string { const was = judged ? `judged against trunk ${twelve(judged)}` : "judged against a trunk it does not name"; return `out of date: ${was}, the trunk is now ${twelve(now)}; judged again on a recheck or when it is merged`; } /** Whether a pending gate says a check of the head is asked and not yet answered: waiting in the build queue, * building on a machine (gate.ts), or asked again by a recheck (mesh-delivery). Such a check is not asked again. */ export function checkOnItsWay(gate: HeadCheck | undefined): boolean { if (!gate || gate.state !== "pending") return false; const d = gate.description ?? ""; return d.startsWith("waiting in the build queue") || d.startsWith("building on ") || d.startsWith("checking again"); } /** * The statuses a trunk's move puts back to pending on one open pull request's head, from what the forge holds on it * now: none when it has no merge check yet, when a check is pending (on its way, or already out of date — its * verdict is weighed against the trunk when it lands), or when its verdict judged the trunk's head. Otherwise the * gate, and the repository's own check when the head has one — a pending repo-check a verdict will not set would * stand for ever (novox/hq issue 305's rule). */ export function outOfDateStatuses(checks: Record, trunkHead: string): CommitStatus[] { const gate = checks[CHECK_CONTEXT]; if (!gate || gate.state === "pending") return []; const judged = trunkNamed(gate.description); if (judged && sameCommit(judged, trunkHead)) return []; const description = outOfDateWords(judged, trunkHead); const out: CommitStatus[] = [{ state: "pending", context: CHECK_CONTEXT, description }]; if (checks[REPO_CHECK_CONTEXT]) out.push({ state: "pending", context: REPO_CHECK_CONTEXT, description }); return out; } /** Where the merge tool stands with a head's verdict and the trunk's head (ADR 0323 §5). */ export interface VerdictAtTrunk { /** The verdict judged the trunk's head: the required statuses decide, as before. */ current: boolean; /** A check of the head is asked and not yet answered: nothing is asked again. */ onItsWay: boolean; /** The trunk commit the verdict names, when it names one. */ judged?: string; } export function verdictAtTrunk(checks: Record, trunkHead: string): VerdictAtTrunk { const gate = checks[CHECK_CONTEXT]; const judged = trunkNamed(gate?.description); if (checkOnItsWay(gate)) return { current: false, onItsWay: true, judged }; return { current: !!gate && gate.state !== "pending" && !!judged && sameCommit(judged, trunkHead), onItsWay: false, judged }; } /** The pending words the merge tool's ask of a check is said with: in the build queue from now, as an announced head's. */ export function askedWords(at: Date, trunkHead: string): string { const two = (n: number) => String(n).padStart(2, "0"); return `waiting in the build queue since ${two(at.getHours())}:${two(at.getMinutes())}: asked by the merge tool ` + `against trunk ${twelve(trunkHead)}`; } /** The comment a verdict that landed out of date leaves (ADR 0323 §7: kept and shown): its own account when it has * one, else what it judged and said, and that it is out of date. */ export function lateVerdictComment(c: { commit: string; trunk?: string; verdict: string }, account: string | null, outOfDate: string): string { const own = account ?? `**Merge check** at \`${c.commit.slice(0, 8)}\`: ${judgedWords(c.commit, c.trunk ?? "")}, ${c.verdict}`; return `${own}\n\n**Out of date when it landed**: ${outOfDate}`; } // --- a trunk's move, weighed -------------------------------------------------------------------------------------- /** What weighing a trunk's move reads of the forge. */ export interface TrunkForge { listOpenPulls(owner: string, repo: string): Promise; branchHead(owner: string, repo: string, branch: string): Promise; } /** What it writes with: the merge check's one writer (gate.ts), which decides in the head's own turn. */ export interface OutOfDateWriter { outOfDate(owner: string, repo: string, sha: string, decide: (checks: Record) => CommitStatus[] | Promise, target?: string): Promise; } /** * Every trunk of one repository that an open pull request merges into, read; for one that moved since it was last read * (`trunks`, by `owner/repo:branch`, which this keeps current), the merge check of every open pull request into it put * out of date (ADR 0323 §4). A trunk first seen is recorded as it is and nothing is put out of date. Bounded however * many are open: one listing of the open pull requests (paged), one read of each trunk, then per open pull request into * a trunk that moved one read and at most two writes, one at a time. A trunk that could not be weighed whole is not * recorded, so the next pass weighs it again. Answers whether the record changed. */ export async function weighTrunks(forge: TrunkForge, gates: OutOfDateWriter, trunks: Record, owner: string, name: string): Promise { let changed = false; try { const open = (await forge.listOpenPulls(owner, name)).filter((p) => p.state === "open" && !!p.head_sha && !!p.base); for (const base of new Set(open.map((p) => String(p.base)))) { const key = `${owner}/${name}:${base}`.toLowerCase(); const head = await forge.branchHead(owner, name, base); const before = trunks[key]; if (before && sameCommit(before, head)) continue; if (before) { let put = 0; for (const pull of open.filter((p) => p.base === base)) { // The trunk is read again in the head's own turn: a verdict judged against a head newer than the pass's first // read — the trunk moved again meanwhile, and a check answered — is not put out of date. const written = await gates.outOfDate(owner, name, String(pull.head_sha), async (checks) => outOfDateStatuses(checks, await forge.branchHead(owner, name, base)), pull.html_url || undefined); if (written.length) put++; } console.log(`[gitea] ${owner}/${name}'s ${base} moved from ${twelve(before)} to ${twelve(head)}: ${put} open pull ` + `request(s) into it put out of date, nothing run (novox/hq ADR 0323)`); } trunks[key] = head; changed = true; } } catch (err) { console.error(`[gitea] ${owner}/${name}: whether a trunk moved could not be weighed, so it is weighed again on the next ` + `pass — ${err instanceof Error ? err.message : err}`); } return changed; } // --- how long the last check of a repository took ------------------------------------------------------------- /** How long one repository's last check took, and when it was heard: kept by the watcher, read by the merge tool. */ export interface Took { took: string; at: string; head?: string; } /** The file the watcher keeps each repository's last check's duration in, beside its other records. */ export function tookFile(stateDir: string | undefined): string | null { return stateDir ? `${stateDir.replace(/\/+$/, "")}/checks-took.json` : null; } function readTookAll(file: string | null): Record { if (!file || !existsSync(file)) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } /** Keep how long a repository's check took. A record that cannot be written is said, never fatal to the verdict. */ export function keepTook(file: string | null, repository: string, took: Took): void { if (!file || !took.took) return; try { const all = readTookAll(file); all[repository.toLowerCase()] = took; mkdirSync(dirname(file), { recursive: true }); writeFileSync(file + ".tmp", JSON.stringify(all)); renameSync(file + ".tmp", file); } catch (err) { console.error(`[gitea] how long ${repository}'s check took could not be kept — ${err instanceof Error ? err.message : err}`); } } /** How long a repository's last check took, in words for the merge tool's refusal. */ export function tookWords(file: string | null, repository: string): string { const t = readTookAll(file)[repository.toLowerCase()]; if (!t) return `how long a check of ${repository} takes is not known yet: no verdict of it was heard since this was kept`; return `the last check of ${repository} took ${t.took} (heard ${t.at})`; } // --- the announcement of a head, shared by the watcher and the merge tool --------------------------------------- /** `pull.updated` for one open pull request's head: what the controller asks the build seat to check (novox/hq to-be * 45 §9). The merge tool's ask is this same announcement (ADR 0323 §5). */ export async function announcementOf(client: GiteaClient, owner: string, name: string, cloneURL: string | undefined, pull: GiteaPull): Promise> { const files = await client.listPullFiles(owner, name, pull.number); const head = String(pull.head_sha); // What the controller maps the change onto the mesh's module graph with (novox/hq ADR 0237 as amended): which // changed directories hold a module at the head — the merge's rule (issue 278) — and whether the head holds the // repository's own merge-check.sh. Not said when it could not be read; the controller then reads the change as // touching everything built from the repository. let moduleDirs: string[] | null = null; let mergeCheck: boolean | null = null; try { if (!files.truncated) moduleDirs = await client.moduleDirsAt(owner, name, head, files.paths); mergeCheck = await client.holdsFile(owner, name, head, "merge-check.sh"); } catch (err) { console.error(`[gitea] ${owner}/${name}#${pull.number}: what the head holds could not be read — ${err instanceof Error ? err.message : err}`); } // The base branch's head as the head is announced: the trunk commit a verdict no build judges is said against // (novox/hq ADR 0323 §3). Not said when it cannot be read; that verdict then names none. const baseSHA = pull.base ? await client.branchHead(owner, name, String(pull.base)).catch(() => undefined) : undefined; return { owner, repo: name, number: pull.number, ...(baseSHA ? { base_sha: baseSHA } : {}), title: pull.title, body: pull.body, base: pull.base, head: pull.head, head_sha: pull.head_sha, clone_url: cloneURL, html_url: pull.html_url, paths: files.paths, paths_truncated: files.truncated, removed: files.removed, ...(moduleDirs ? { module_dirs: moduleDirs, module_dirs_said: true } : {}), ...(mergeCheck !== null ? { merge_check: mergeCheck, merge_check_said: true } : {}), }; }