Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
// **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;
|
||||
}
|
||||
Reference in New Issue
Block a user