Files
mesh-catalog/modules/gitea/siblings.ts
T
mesh-toolsandClaude de9d29dc65
mesh/merge-gate head de9d29dc657d merged into trunk 62ec68355cd1 — pass: builds gitea, mesh-delivery → novox; no bus step; every machine composes with the …
mesh/repo-check head de9d29dc657d merged into trunk 62ec68355cd1 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/delivery-group group fix/867-group-siblings delivered: every member is delivered
Keep a group member's siblings and refuse its merge until the group passed and every sibling is reviewed (issue 867, ADR 0382)
mesh-delivery's groups names each member's pull request, head, base and whether it is open (member_details), so the controller can read a member's siblings beside its check. The forge's holder keeps the siblings each verdict read, puts that verdict out of date when a sibling gets a new head, closes, or its base moves by anything but its own merge, and the merge tool merges such a member only when mesh/delivery-group passed at the heads read and each sibling head is approved. The statuses name the siblings, or say they could not be learned.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 23:27:13 +00:00

287 lines
14 KiB
TypeScript

// **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<string, SiblingEntry>;
/** 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<string, unknown>;
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<GiteaPull>;
branchHead(owner: string, repo: string, branch: string): Promise<string>;
commitParents(owner: string, repo: string, sha: string): Promise<string[]>;
}
/** 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<string, HeadCheck>, 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<boolean> {
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<Record<string, HeadCheck>>;
/** Whether the sibling's head carries an approving review at that head. */
approvedAt(owner: string, repo: string, number: number, head: string): Promise<boolean>;
}
/**
* 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<string | undefined> {
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;
}