// **A delivery group's member is checked beside the other members' heads, and a sibling's move puts that verdict out of // date** (novox/hq ADR 0382 §5, §6; issue 867). // // The controller's verdict (`checked`) names the siblings its check read: each as the sibling's repository, pull // request, head, base branch and the base commit the sibling's head was merged onto. This module, the forge's holder, // keeps them per open pull request, beside its other records, and: // // - on its own hearing of a sibling's new head, of its close unmerged, or of a move of its base, sets // mesh/merge-gate and mesh/repo-check of every member whose verdict names that sibling back to pending, with the // reason. Nothing is run for it. The one move that does not count is the sibling's own merge of the head that was // read onto the base commit that was read (in either order of the two merges); // - lets the merge tool refuse a verdict that read siblings unless mesh/delivery-group passed at the very heads it // read, and every sibling head it read carries an approving review at that head (merge.ts). // // A verdict without siblings (a controller from before ADR 0382) is none of this: nothing is kept and nothing changes. // Pure functions and one small file 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, GiteaPull, HeadCheck } from "./client.js"; import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, clipped } from "./pulls.js"; import { sameCommit, twelve, type OutOfDateWriter } from "./trunk.js"; /** The status the delivery group's owner sets on each member's head (novox/hq ADR 0239). */ export const GROUP_CONTEXT = "mesh/delivery-group"; /** One sibling a verdict read, as the controller names it (contract of ADR 0382 §4). */ export interface SiblingNamed { repository: string; number: number; head: string; base: string; base_commit: string; /** Set here, never by the controller: the sibling merged its head onto its base commit read, so it is no sibling's * move any more. */ merged?: boolean; } /** What is kept of one member's latest verdict that read siblings: the head it was of and the siblings it named. */ export interface SiblingEntry { head: string; siblings: SiblingNamed[]; } /** The records, by `owner/repo#number` of the member. */ export type SiblingRecord = Record; /** The member's key. */ export function memberKey(owner: string, repo: string, number: number): string { return `${owner}/${repo}#${number}`.toLowerCase(); } /** The siblings a verdict names, trimmed to what is kept; none when it names none or names one badly. */ export function siblingsOf(named: unknown): SiblingNamed[] { if (!Array.isArray(named)) return []; const out: SiblingNamed[] = []; for (const s of named) { const r = s as Record; const repository = String(r?.repository ?? ""), head = String(r?.head ?? ""), base = String(r?.base ?? ""); const number = Number(r?.number); if (!repository || !head || !base || !Number.isInteger(number) || number <= 0) continue; out.push({ repository, number, head, base, base_commit: String(r?.base_commit ?? "") }); } return out; } /** The siblings as a status names them: "mesh-host#114@abcdef123456, …". */ export function siblingWords(siblings: SiblingNamed[]): string { return siblings.map((s) => `${s.repository.split("/").pop()}#${s.number}@${twelve(s.head)}`).join(", "); } /** What a status of a verdict that read siblings carries, after the commits it judged. */ export function besideWords(siblings: SiblingNamed[]): string { // Short, so that the verdict word and the start of its reason survive the forge's 140 characters: the first sibling // by its pull request and 8 characters of its head, and how many more. The whole list is the holder's record and the comment's. const first = siblings[0]; const more = siblings.length > 1 ? ` +${siblings.length - 1} more` : ""; return `beside ${first.repository.split("/").pop()}#${first.number}@${twelve(first.head).slice(0, 8)}${more}`; } /** The mark a status carries when the siblings could not be learned (ADR 0382 §1): said, never silent. */ export const UNKNOWN_MARK = "siblings unknown"; /** Whether a status names that its verdict read siblings (so that a record lost does not read as none). */ export function namesSiblings(description: string | undefined): boolean { return / beside [\w.-]+#\d+@[0-9a-f]{8}\b/.test(description ?? ""); } /** Whether a status is a verdict put out of date by a sibling's move. */ export function siblingOutOfDate(description: string | undefined): boolean { return /^out of date: judged beside /.test(description ?? ""); } // --- the record on disk ---------------------------------------------------------------------------------------- /** The file the holder keeps the siblings its verdicts name in, beside its other records. */ export function siblingsFile(stateDir: string | undefined): string | null { return stateDir ? `${stateDir.replace(/\/+$/, "")}/siblings-named.json` : null; } export function readSiblings(file: string | null): SiblingRecord { if (!file || !existsSync(file)) return {}; try { return (JSON.parse(readFileSync(file, "utf8")) as { members?: SiblingRecord }).members ?? {}; } catch { return {}; } } export function keepSiblings(file: string | null, record: SiblingRecord): void { if (!file) return; try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file + ".tmp", JSON.stringify({ members: record })); renameSync(file + ".tmp", file); } catch (err) { console.error(`[gitea] the siblings a verdict named could not be kept — ${err instanceof Error ? err.message : err}`); } } /** A member's latest verdict, kept: its siblings when it read some, else the entry removed. Answers whether it changed. */ export function noteVerdict(record: SiblingRecord, key: string, head: string, siblings: SiblingNamed[]): boolean { if (siblings.length === 0) { if (!(key in record)) return false; delete record[key]; return true; } record[key] = { head, siblings }; return true; } // --- what a sibling's move is --------------------------------------------------------------------------------- /** What weighing a sibling reads of the forge. */ export interface SiblingForge { getPullRequest(owner: string, repo: string, index: number): Promise; branchHead(owner: string, repo: string, branch: string): Promise; commitParents(owner: string, repo: string, sha: string): Promise; } /** The reason a verdict that named this sibling is out of date, from what the forge holds now, or nothing; and whether * the sibling has merged its head read onto the base commit read, which ends it as a sibling. */ export async function siblingMoved(forge: SiblingForge, s: SiblingNamed): Promise<{ reason?: string; merged?: boolean }> { const [owner, repo] = s.repository.split("/"); const who = `${repo}#${s.number}`; const pull = await forge.getPullRequest(owner, repo, s.number); if (pull.merged) { // Its own merge of the head that was read onto the base commit that was read: the head the merge joined is the head // read, and the commit it was made onto is the base commit read. Any other merge is a move of the base. const parents = pull.merge_commit_sha ? await forge.commitParents(owner, repo, pull.merge_commit_sha).catch(() => []) : []; const own = sameCommit(pull.head_sha, s.head) && parents.length >= 1 && sameCommit(parents[0], s.base_commit) && (parents.length === 1 || sameCommit(parents[1], s.head)); if (own) return { merged: true }; return { reason: `${who} was merged otherwise than its head ${twelve(s.head)} onto ${twelve(s.base_commit)}` }; } if (pull.state !== "open") return { reason: `${who} was closed unmerged` }; if (!sameCommit(pull.head_sha, s.head)) { return { reason: `${who} at ${twelve(s.head)}, it is now at ${twelve(pull.head_sha)}` }; } const base = await forge.branchHead(owner, repo, s.base); if (!sameCommit(base, s.base_commit)) { return { reason: `${who} merged onto ${twelve(s.base_commit)}, its ${s.base} is now ${twelve(base)}` }; } return {}; } /** A verdict out of date because a sibling moved, as its statuses say it (ADR 0382 §5; the wording of ADR 0323 §4). */ export function siblingOutOfDateWords(reason: string): string { return clipped(`out of date: judged beside ${reason}; judged again on a recheck or when it is merged`); } /** The statuses a sibling's move puts back to pending on one member's head, from what the forge holds on it now: none * when it has no merge check, or its gate is pending (a check on its way, or already out of date). */ export function siblingStatuses(checks: Record, reason: string): CommitStatus[] { const gate = checks[CHECK_CONTEXT]; if (!gate || gate.state === "pending") return []; const description = siblingOutOfDateWords(reason); 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; } /** What weighing a sibling's move writes with, beside the forge's reads. */ export interface SiblingWeighing extends SiblingForge {} /** * A sibling moved (a new head, a close, a base move, a merge): every member whose latest verdict names it has its * merge check put back to pending with the reason. `repository` and, when the event names one, `number` say which * sibling. A member whose head is no longer the one its verdict was of is dropped from the record: its new head is * checked afresh. Nothing is run. Answers whether the record changed. A read that fails leaves that member for the next * move to weigh, and is said. */ export async function weighSiblings(forge: SiblingWeighing, gates: OutOfDateWriter, record: SiblingRecord, repository: string, number?: number): Promise { let changed = false; const repo = repository.toLowerCase(); for (const [key, entry] of Object.entries(record)) { const [path] = key.split("#"); const [owner, name] = path.split("/"); const memberNumber = Number(key.split("#")[1]); // A member that is merged or no longer open has no verdict to put out of date: its entry goes (the record must not // grow without bound, and a merged head is never re-pended by a sibling's later push). Weighed when the event is the // member's own, or names one of its siblings. const own = path === repo && (number === undefined || number === memberNumber); if (own || entry.siblings.some((s) => !s.merged && s.repository.toLowerCase() === repo && (number === undefined || s.number === number))) { try { const member = await forge.getPullRequest(owner, name, memberNumber); if (member.merged || member.state !== "open") { delete record[key]; changed = true; continue; } } catch (err) { console.error(`[gitea] ${key}: whether it is still open could not be read — ${err instanceof Error ? err.message : err}`); } } for (const s of entry.siblings) { if (s.merged || s.repository.toLowerCase() !== repo || (number !== undefined && s.number !== number)) continue; try { const moved = await siblingMoved(forge, s); if (moved.merged) { s.merged = true; changed = true; continue; } if (!moved.reason) continue; const member = await forge.getPullRequest(owner, name, memberNumber); if (!sameCommit(member.head_sha, entry.head)) { delete record[key]; changed = true; break; } const written = await gates.outOfDate(owner, name, entry.head, (checks) => siblingStatuses(checks, moved.reason!), member.html_url || undefined); if (written.length) { console.log(`[gitea] ${key}: its verdict was judged beside ${moved.reason}: out of date, nothing run (novox/hq ADR 0382)`); } break; } catch (err) { console.error(`[gitea] ${key}: whether ${s.repository}#${s.number} moved could not be weighed, so it is weighed again ` + `on its next move — ${err instanceof Error ? err.message : err}`); } } } return changed; } // --- the merge tool's rule (ADR 0382 §6) ---------------------------------------------------------------------- /** What the rule reads beside the pull request being merged. */ export interface GroupForge { headChecks(owner: string, repo: string, sha: string): Promise>; /** Whether the sibling's head carries an approving review at that head. */ approvedAt(owner: string, repo: string, number: number, head: string): Promise; } /** * What stops a pull request whose verdict read siblings from merging, in words, or nothing: mesh/delivery-group must be * a success at its own head and at every sibling head the verdict read, and every sibling head read must carry an * approving review at that head. Every missing thing is named. */ export async function groupRefusal(forge: GroupForge, self: { owner: string; repo: string; number: number; head: string }, siblings: SiblingNamed[]): Promise { const missing: string[] = []; const members = [{ repository: `${self.owner}/${self.repo}`, number: self.number, head: self.head, sibling: false }, ...siblings.map((s) => ({ repository: s.repository, number: s.number, head: s.head, sibling: true }))]; for (const m of members) { const [owner, repo] = m.repository.split("/"); const at = `${repo}#${m.number}@${twelve(m.head)}`; let group: HeadCheck | undefined; try { group = (await forge.headChecks(owner, repo, m.head))[GROUP_CONTEXT]; } catch (err) { missing.push(`${GROUP_CONTEXT} at ${at} could not be read (${err instanceof Error ? err.message : err})`); continue; } if (group?.state !== "success") missing.push(`${GROUP_CONTEXT} is ${group?.state ?? "missing"} at ${at}`); if (!m.sibling) continue; try { if (!(await forge.approvedAt(owner, repo, m.number, m.head))) missing.push(`${at} carries no approving review at that head`); } catch (err) { missing.push(`whether ${at} carries an approving review could not be read (${err instanceof Error ? err.message : err})`); } } return missing.length ? missing.join("; ") : undefined; }