// **One writer of a head's mesh/merge-gate** (novox/hq issue 446). // // A pending check says where it is: "waiting in the build queue since …" when the head is announced. The forge keeps only // the newest status of a context, so two writers cannot order their writes; this module is the one writer of // mesh/merge-gate, and every write of one head's gate goes through one chain, in the order it was asked. A recheck's // pending (novox/hq issue 456) and a trunk's move (ADR 0323 §4) take their turns on the same chain. No verdict is written // here any more: the controller's `checked` is no event (ADR 0571 clause 13), and main requires the checker's mesh/check. import { CHECK_CONTEXT, pendingWords } from "./pulls.js"; import type { CommitStatus, HeadCheck } from "./client.js"; /** What the gate writer needs of the forge: a status set, and a head's statuses as the forge holds them. */ export interface GateForge { setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise; /** The head's statuses by context, the newest of each, with their words (novox/hq ADR 0323). */ headChecks(owner: string, repo: string, sha: string): Promise>; } /** Whether a repository as the mesh clones it (`http://forge/novox/hq.git`) is owner/repo. */ export function clonedAs(url: string | undefined, owner: string, repo: string): boolean { const u = (url ?? "").trim().toLowerCase().replace(/\.git$/, ""); const full = `${owner}/${repo}`.toLowerCase(); return u === full || u.endsWith("/" + full); } interface Head { /** The chain the head's writes take their turns on. */ chain: Promise; } export class GateWriter { private heads = new Map(); private forge: GateForge; private now: () => Date; constructor(forge: GateForge, now: () => Date = () => new Date()) { this.forge = forge; this.now = now; } private head(owner: string, repo: string, sha: string): Head { const key = `${owner}/${repo}@${sha}`.toLowerCase(); let h = this.heads.get(key); if (!h) { h = { chain: Promise.resolve() }; this.heads.set(key, h); // Kept for the heads still in play; the oldest go first, past what any forge holds open at once. if (this.heads.size > 2000) this.heads.delete(this.heads.keys().next().value as string); } return h; } /** One turn on the head's chain; a turn that fails never stops the next. Its failure is the caller's. */ private turn(h: Head, fn: () => Promise): Promise { const run = h.chain.then(fn); h.chain = run.catch(() => {}); return run; } /** A turn whose failure is said here: a pending word not written is not worth the event again. */ private said(h: Head, what: string, fn: () => Promise): Promise { return this.turn(h, fn).catch((err) => { console.error(`[gitea] ${what} — ${err instanceof Error ? err.message : err}`); }); } /** The head announced: it waits in the build queue from now. A new check of the head, so no verdict yet. */ pending(owner: string, repo: string, sha: string): Promise { const h = this.head(owner, repo, sha); const at = this.now(); return this.said(h, `${owner}/${repo}@${sha.slice(0, 8)}: could not say a check is pending`, () => this.forge.setCommitStatus(owner, repo, sha, { state: "pending", context: CHECK_CONTEXT, description: pendingWords(at) })); } /** * A recheck's pending, asked through the gitea_commit_status tool (novox/hq issue 456): it takes its turn after every * write already asked for, so it lands over them. Any other status of the merge check takes its turn on the same * chain. A write that fails fails the caller. */ rechecked(owner: string, repo: string, sha: string, status: CommitStatus): Promise { return this.turn(this.head(owner, repo, sha), () => this.forge.setCommitStatus(owner, repo, sha, status)); } /** * **A trunk's move puts the head's verdict out of date** (novox/hq ADR 0323 §4): its merge check statuses are put back * to pending, with the reason, in their turn after every write of the head already asked for — a verdict in flight * lands first. What is written is decided in the turn, from the statuses the forge holds then (`decide`, trunk.ts's * outOfDateStatuses): a check on its way, or a verdict already judged against the trunk's head, is left as it is. It * opens no check — nothing is asked — so a verdict of a check asked before it keeps its weight. Answers what it wrote; * a write that fails fails the caller, and the move is weighed again on the next pass. */ outOfDate(owner: string, repo: string, sha: string, decide: (checks: Record) => CommitStatus[] | Promise, target?: string): Promise { const h = this.head(owner, repo, sha); const written: CommitStatus[] = []; return this.turn(h, async () => { for (const s of await decide(await this.forge.headChecks(owner, repo, sha))) { await this.forge.setCommitStatus(owner, repo, sha, target ? { ...s, target_url: target } : s); written.push(s); } }).then(() => written); } }