Files
mesh-catalog/modules/gitea/test/siblings.test.ts
T

270 lines
16 KiB
TypeScript

// **A delivery group's member is judged beside its siblings, and a sibling's move puts that verdict out of date**
// (novox/hq ADR 0382 §4-§6, issue 867): the holder keeps the siblings each verdict names; a sibling's new head, close
// unmerged or base move (other than its own merge of the head that was read) sets both statuses of every member whose
// verdict names it back to pending, and runs nothing; a member merges only with its group passed and every sibling head
// read approved.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { besideWords, groupRefusal, keepSiblings, namesSiblings, noteVerdict, readSiblings, siblingOutOfDate, siblingsFile,
siblingsOf, siblingStatuses, weighSiblings, type SiblingRecord } from "../dist/siblings.js";
import { weighTrunks } from "../dist/trunk.js";
import { commentFor, statusesFor } from "../dist/pulls.js";
const h = (c: string) => c.repeat(40);
const MEMBER_HEAD = h("a"), HOST_HEAD = h("b"), HOST_BASE = h("c"), HOST_NEW = h("d"), HOST_MOVED = h("e"), MERGE = h("f");
const sibling = { repository: "novox/mesh-host", number: 114, head: HOST_HEAD, base: "main", base_commit: HOST_BASE };
const verdict = { owner: "novox", repo: "mesh-controller", number: 346, commit: MEMBER_HEAD, verdict: "pass", summary: "x", id: "build-1",
trunk: h("1").slice(0, 12), siblings: [sibling], judge_from: "sibling novox/mesh-controller#346 merge 123456789abc",
gate: { verdict: "pass", summary: "every machine composes ".repeat(10) }, "repo-check": { verdict: "pass", summary: "ok" } };
test("both statuses name the siblings the verdict read, keeping the verdict word and the start of its reason within 140 characters", () => {
// A realistic controller summary for a failure, long enough that the forge clips it.
const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module on ace claims after this change";
const c = { ...verdict, gate: { verdict: "fail", summary: reason }, "repo-check": { verdict: "fail", summary: "its merge-check.sh failed: internal/link/check.go:145:2: undefined" } };
const [gate, repo] = statusesFor(c);
for (const s of [gate, repo]) {
assert.ok(s.description.startsWith(`head ${MEMBER_HEAD.slice(0, 12)} merged into trunk 111111111111 beside mesh-host#114@bbbbbbbb — fail: `), s.description);
assert.ok(s.description.length <= 140, `${s.description.length} characters`);
assert.ok(namesSiblings(s.description));
}
assert.match(gate.description, /fail: 2 of 4 machines do not compose: ace: mesh-host/, gate.description);
assert.match(repo.description, /fail: its merge-check\.sh failed: internal/, repo.description);
assert.equal(besideWords([...siblingsOf([sibling]), ...siblingsOf([{ ...sibling, number: 115 }])]), "beside mesh-host#114@bbbbbbbb +1 more");
const comment = commentFor({ ...verdict, gate: { verdict: "fail", summary: "no" } })!;
assert.match(comment, /Siblings read beside it: novox\/mesh-host#114 `bbbbbbbbbbbb` merged onto `cccccccccccc` \(main\); judged by sibling novox\/mesh-controller#346 merge/);
});
test("a verdict that could not learn its siblings says so on the status and in the comment, and names none", () => {
const { siblings, ...unknown } = { ...verdict, siblings_unknown: "mesh-delivery did not answer" };
const reason = "2 of 4 machines do not compose: ace: mesh-host.service needs seat node-reads, which no module claims";
for (const s of statusesFor({ ...unknown, gate: { verdict: "fail", summary: reason } })) {
assert.match(s.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 siblings unknown — (fail|pass): /, s.description);
assert.ok(s.description.length <= 140);
assert.equal(namesSiblings(s.description), false, "it read no siblings, so the merge tool asks for none");
}
assert.match(commentFor({ ...unknown, gate: { verdict: "fail", summary: "no" } })!, /Siblings could not be learned: mesh-delivery did not answer/);
});
test("a verdict without siblings is said exactly as before (an older controller)", () => {
const { siblings, judge_from, ...old } = verdict;
const [gate] = statusesFor(old);
assert.equal(namesSiblings(gate.description), false);
assert.match(gate.description, /^head aaaaaaaaaaaa merged into trunk 111111111111 — pass: /);
assert.deepEqual(siblingsOf(undefined), []);
assert.deepEqual(siblingsOf([{ repository: "novox/x" }]), [], "a sibling named without its head is none");
});
test("the siblings a verdict names are kept per member, replaced by its next verdict, and removed by one that read none", () => {
const file = siblingsFile(mkdtempSync(join(tmpdir(), "gitea-siblings-")));
const record: SiblingRecord = {};
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), false);
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, siblingsOf([sibling])), true);
keepSiblings(file, record);
assert.deepEqual(readSiblings(file), record, "kept where the holder keeps its state");
assert.equal(noteVerdict(record, "novox/mesh-controller#346", MEMBER_HEAD, []), true);
assert.deepEqual(record, {});
assert.deepEqual(readSiblings(null), {});
});
// --- a sibling's move -----------------------------------------------------------------------------------------
interface World {
host: { merged: boolean; state: string; head_sha: string; merge_commit_sha?: string };
hostBase: string;
parents: string[];
member: { head_sha: string; state: string; merged: boolean };
checks: Record<string, { state: string; description?: string }>;
written: { sha: string; context: string; state: string; description: string }[];
reads: string[];
}
const world = (): World => ({ host: { merged: false, state: "open", head_sha: HOST_HEAD }, hostBase: HOST_BASE, parents: [],
member: { head_sha: MEMBER_HEAD, state: "open", merged: false },
checks: { "mesh/merge-gate": { state: "success", description: "head aaaaaaaaaaaa merged into trunk 111111111111 beside mesh-host#114@bbbbbbbb — pass: x" },
"mesh/repo-check": { state: "success", description: "pass: y" } },
written: [], reads: [] });
const forgeOf = (w: World) => ({
async getPullRequest(owner: string, repo: string, n: number) {
w.reads.push(`${owner}/${repo}#${n}`);
return repo === "mesh-host" ? { number: n, title: "", html_url: "", ...w.host } : { number: n, title: "", html_url: "", merged: false, ...w.member };
},
async branchHead(_o: string, _r: string, _b: string) { return w.hostBase; },
async commitParents() { return w.parents; },
});
const gatesOf = (w: World) => ({
async outOfDate(_o: string, _r: string, sha: string, decide: (c: any) => any[] | Promise<any[]>) {
const put = await decide(w.checks);
for (const s of put) { w.written.push({ sha, ...s }); w.checks[s.context] = { state: s.state, description: s.description }; }
return put;
},
});
const recordOf = (): SiblingRecord => ({ "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } });
const weigh = (w: World, record: SiblingRecord, repository = "novox/mesh-host", number?: number) =>
weighSiblings(forgeOf(w) as any, gatesOf(w) as any, record, repository, number);
test("a sibling's new head puts both statuses of the member back to pending, with the reason, and runs nothing", async () => {
const w = world();
w.host.head_sha = HOST_NEW;
await weigh(w, recordOf(), "novox/mesh-host", 114);
assert.deepEqual(w.written.map((s) => [s.sha, s.context, s.state]),
[[MEMBER_HEAD, "mesh/merge-gate", "pending"], [MEMBER_HEAD, "mesh/repo-check", "pending"]]);
assert.equal(w.written[0].description,
"out of date: judged beside mesh-host#114 at bbbbbbbbbbbb, it is now at dddddddddddd; judged again on a recheck or when it is merged");
assert.ok(siblingOutOfDate(w.written[0].description));
// Already pending (out of date, or a check on its way): not written again.
w.written.length = 0;
await weigh(w, recordOf(), "novox/mesh-host", 114);
assert.deepEqual(w.written, []);
});
test("a sibling closed unmerged puts the member's verdict out of date", async () => {
const w = world();
w.host.state = "closed";
await weigh(w, recordOf(), "novox/mesh-host", 114);
assert.equal(w.written.length, 2);
assert.match(w.written[0].description, /^out of date: judged beside mesh-host#114 was closed unmerged/);
});
test("a move of the sibling's base puts it out of date, naming both commits", async () => {
const w = world();
w.hostBase = HOST_MOVED;
await weigh(w, recordOf());
assert.equal(w.written.length, 2);
assert.match(w.written[0].description, /mesh-host#114 merged onto cccccccccccc, its main is now eeeeeeeeeeee/);
});
test("the sibling's own merge of the head read onto the base read does not, in either order of the two merges", async () => {
// The sibling merges first (a merge commit of the base commit read and the head read).
const w = world();
Object.assign(w.host, { merged: true, state: "closed", merge_commit_sha: MERGE });
w.hostBase = MERGE;
w.parents = [HOST_BASE, HOST_HEAD];
const record = recordOf();
assert.equal(await weigh(w, record), true, "the record changes: the sibling is marked merged");
assert.deepEqual(w.written, []);
assert.equal(record["novox/mesh-controller#346"].siblings[0].merged, true);
// A move of that base afterwards is no sibling's move: the sibling is merged.
w.hostBase = HOST_MOVED;
await weigh(w, record);
assert.deepEqual(w.written, []);
// The member merges first: its own repository moves, which no member's verdict names as a sibling.
const m = world();
m.member.merged = true;
await weigh(m, recordOf(), "novox/mesh-controller");
assert.deepEqual(m.written, []);
// And the sibling then merges: still not a move.
Object.assign(m.host, { merged: true, state: "closed", merge_commit_sha: MERGE });
m.parents = [HOST_BASE, HOST_HEAD];
await weigh(m, recordOf());
assert.deepEqual(m.written, []);
// A squash is one parent: the base commit read.
const q = world();
Object.assign(q.host, { merged: true, state: "closed", merge_commit_sha: MERGE });
q.parents = [HOST_BASE];
await weigh(q, recordOf());
assert.deepEqual(q.written, []);
});
test("a merge of another head, or onto another base, is a move", async () => {
const other = world();
Object.assign(other.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW });
other.parents = [HOST_BASE, HOST_NEW];
await weigh(other, recordOf());
assert.equal(other.written.length, 2);
const onto = world();
Object.assign(onto.host, { merged: true, state: "closed", merge_commit_sha: MERGE });
onto.parents = [HOST_MOVED, HOST_HEAD];
await weigh(onto, recordOf());
assert.equal(onto.written.length, 2);
});
test("only the members whose verdict names the sibling are touched, and a member that moved on is dropped", async () => {
// Another sibling's move, or another repository's: nothing read, nothing written.
const w = world();
w.host.head_sha = HOST_NEW;
await weigh(w, recordOf(), "novox/mesh-lab");
await weigh(w, recordOf(), "novox/mesh-host", 999);
assert.deepEqual(w.written, []);
// A verdict that is none (a gate not yet judged) is left alone.
const none = world();
none.host.head_sha = HOST_NEW;
none.checks = {};
await weigh(none, recordOf());
assert.deepEqual(none.written, []);
// The member has a new head: its old verdict is no verdict; the record is dropped, nothing written to the old head.
const moved = world();
moved.host.head_sha = HOST_NEW;
moved.member.head_sha = h("9");
const record = recordOf();
assert.equal(await weigh(moved, record), true);
assert.deepEqual(moved.written, []);
assert.deepEqual(record, {});
// A repository without a repo-check gets no pending one a verdict would never set.
assert.deepEqual(siblingStatuses({ "mesh/merge-gate": { state: "success" } }, "x").map((s) => s.context), ["mesh/merge-gate"]);
});
test("a trunk that moved tells the siblings' weighing; one first seen does not", async () => {
const told: string[] = [];
const forge = { listOpenPulls: async () => [{ number: 1, title: "", state: "open", merged: false, head_sha: h("2"), base: "main", html_url: "" }],
branchHead: async () => h("3") };
const gates = { outOfDate: async () => [] };
const trunks: Record<string, string> = {};
await weighTrunks(forge as any, gates as any, trunks, "novox", "mesh-host", async (b) => { told.push(b); });
assert.deepEqual(told, [], "first seen");
trunks["novox/mesh-host:main"] = h("4");
await weighTrunks(forge as any, gates as any, trunks, "novox", "mesh-host", async (b) => { told.push(b); });
assert.deepEqual(told, ["main"]);
});
// --- the merge rule -------------------------------------------------------------------------------------------
test("the group rule names every missing thing: the group status at each head read, and each sibling head's review", async () => {
const self = { owner: "novox", repo: "mesh-controller", number: 346, head: MEMBER_HEAD };
const statuses: Record<string, string> = {};
const approved = new Set<string>();
const forge = {
headChecks: async (_o: string, r: string, sha: string) => (statuses[`${r}@${sha}`] ? { "mesh/delivery-group": { state: statuses[`${r}@${sha}`] } } : {}),
approvedAt: async (_o: string, r: string, n: number, head: string) => approved.has(`${r}#${n}@${head}`),
};
const siblings = siblingsOf([sibling]);
const none = await groupRefusal(forge, self, siblings);
assert.match(none!, /mesh\/delivery-group is missing at mesh-controller#346@aaaaaaaaaaaa; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review at that head/);
statuses[`mesh-controller@${MEMBER_HEAD}`] = "success";
statuses[`mesh-host@${HOST_HEAD}`] = "pending";
assert.match((await groupRefusal(forge, self, siblings))!, /^mesh\/delivery-group is pending at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb carries no approving review/);
statuses[`mesh-host@${HOST_HEAD}`] = "success";
assert.match((await groupRefusal(forge, self, siblings))!, /^mesh-host#114@bbbbbbbbbbbb carries no approving review at that head$/);
approved.add(`mesh-host#114@${HOST_HEAD}`);
assert.equal(await groupRefusal(forge, self, siblings), undefined);
// A read that fails is named, never read as a pass.
const broken = { ...forge, approvedAt: async () => { throw new Error("the forge is away"); } };
assert.match((await groupRefusal(broken, self, siblings))!, /could not be read \(the forge is away\)/);
assert.equal(besideWords(siblings), "beside mesh-host#114@bbbbbbbb");
});
test("a merged or closed member's entry is dropped, so a sibling's later push never re-pends its merged head", async () => {
// mesh-host#114 judged beside mesh-controller#346; #114 then merged; a push to #346 follows.
const w = world();
w.host.merged = true; // the member (mesh-host#114) is merged
w.member.head_sha = h("9"); // the sibling (mesh-controller#346) has a new head
const record: SiblingRecord = { "novox/mesh-host#114": { head: HOST_HEAD, siblings: siblingsOf([
{ repository: "novox/mesh-controller", number: 346, head: MEMBER_HEAD, base: "main", base_commit: h("2") }]) } };
assert.equal(await weigh(w, record, "novox/mesh-controller", 346), true);
assert.deepEqual(w.written, [], "the merged head is not put back to pending");
assert.deepEqual(record, {}, "and its entry is gone");
// On its own close or merge the member's entry goes too, whatever the siblings do.
const own = world();
own.host.state = "closed";
const kept = recordOf();
const mine: SiblingRecord = { "novox/mesh-host#114": { head: HOST_HEAD, siblings: kept["novox/mesh-controller#346"].siblings } };
assert.equal(await weigh(own, mine, "novox/mesh-host", 114), true);
assert.deepEqual(mine, {});
assert.deepEqual(own.written, []);
});