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
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>
287 lines
14 KiB
TypeScript
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;
|
|
}
|