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>
270 lines
16 KiB
TypeScript
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, []);
|
|
});
|