mesh/delivery-group group feat/341-a-merge-check-judges-the-merge rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The merge tool's pending bypassed the merge check's one writer from the tools' bundle (issue 456); it is now served beside it. A verdict no build judges names the announced base head, so the merge tool no longer refuses it for ever. A late verdict keeps its account, a trunk is read again in each head's turn so a fresh verdict is not put out of date, and a delivered member counts when a rebased member's group is composed again (novox/hq ADR 0323).
251 lines
14 KiB
TypeScript
251 lines
14 KiB
TypeScript
// **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<string, HeadCheck>, 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<string, HeadCheck>, 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<GiteaPull[]>;
|
|
branchHead(owner: string, repo: string, branch: string): Promise<string>;
|
|
}
|
|
|
|
/** 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<string, HeadCheck>) => CommitStatus[] | Promise<CommitStatus[]>, target?: string): Promise<CommitStatus[]>;
|
|
}
|
|
|
|
/**
|
|
* 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<string, string>, owner: string,
|
|
name: string): Promise<boolean> {
|
|
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<string, Took> {
|
|
if (!file || !existsSync(file)) return {};
|
|
try {
|
|
return JSON.parse(readFileSync(file, "utf8")) as Record<string, Took>;
|
|
} 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<Record<string, unknown>> {
|
|
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 } : {}),
|
|
};
|
|
}
|