The review finishes before the push and its record is a Reviewed-by line in the description, so the mark repeated what the push meant. mesh-delivery asks the operator when a pull request's checks pass, keeps an ask through a trunk move by asking the check again at once, and hands the merge tool the approved heads, which replace the forge review of each sibling.
310 lines
16 KiB
TypeScript
310 lines
16 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 { Refused } from "./guard.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>>;
|
|
}
|
|
|
|
/** The heads the operator's Approve covers (novox/hq ADR 0421 §9, issue 1022), as mesh-delivery hands them to the merge
|
|
* tool: `owner/repo@<full sha>`, one per member, by repository. A malformed entry is refused, never skipped. */
|
|
export function approvedHeadsOf(given: unknown): Map<string, string> | undefined {
|
|
if (given === undefined || given === null) return undefined;
|
|
if (!Array.isArray(given)) throw new Refused("approved_heads must be a list of owner/repo@<full sha>. Nothing was merged");
|
|
const out = new Map<string, string>();
|
|
for (const entry of given) {
|
|
const m = /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)@([0-9a-f]{40}|[0-9a-f]{64})$/i.exec(String(entry));
|
|
if (!m) throw new Refused(`approved_heads holds ${JSON.stringify(entry)}, which is not owner/repo@<full sha>. Nothing was merged`);
|
|
out.set(m[1].toLowerCase(), m[2].toLowerCase());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Why a pull request's head is not one the Approve covers, in words, or nothing: its repository is not named, or names
|
|
* another head. */
|
|
export function notApproved(approved: Map<string, string>, repository: string, number: number, head: string): string | undefined {
|
|
const covered = approved.get(repository.toLowerCase());
|
|
const at = `${repository.split("/")[1]}#${number}@${twelve(head)}`;
|
|
if (covered === undefined) return `${at} is not among the heads the Approve covers`;
|
|
if (covered !== head.toLowerCase()) return `${at} is not the head the Approve covers (${twelve(covered)})`;
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* 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 head read must be one the operator's
|
|
* Approve covers (ADR 0421 §9: the Approve is the review; the merge tool takes the approved heads from mesh-delivery).
|
|
* Every missing thing is named.
|
|
*/
|
|
export async function groupRefusal(forge: GroupForge, self: { owner: string; repo: string; number: number; head: string },
|
|
siblings: SiblingNamed[], approved: Map<string, string> | undefined): Promise<string | undefined> {
|
|
const missing: string[] = [];
|
|
const members = [{ repository: `${self.owner}/${self.repo}`, number: self.number, head: self.head },
|
|
...siblings.map((s) => ({ repository: s.repository, number: s.number, head: s.head }))];
|
|
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 (!approved) missing.push(`${at} is covered by no Approve: the merge was handed no approved heads`);
|
|
else {
|
|
const why = notApproved(approved, m.repository, m.number, m.head);
|
|
if (why) missing.push(why);
|
|
}
|
|
}
|
|
return missing.length ? missing.join("; ") : undefined;
|
|
}
|