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.
271 lines
16 KiB
TypeScript
271 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 { approvedHeadsOf, 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 head the Approve does not cover", async () => {
|
|
const self = { owner: "novox", repo: "mesh-controller", number: 346, head: MEMBER_HEAD };
|
|
const statuses: Record<string, string> = {};
|
|
const forge = {
|
|
headChecks: async (_o: string, r: string, sha: string) => (statuses[`${r}@${sha}`] ? { "mesh/delivery-group": { state: statuses[`${r}@${sha}`] } } : {}),
|
|
};
|
|
const siblings = siblingsOf([sibling]);
|
|
const none = await groupRefusal(forge, self, siblings, undefined);
|
|
assert.match(none!, /mesh\/delivery-group is missing at mesh-controller#346@aaaaaaaaaaaa; mesh-controller#346@aaaaaaaaaaaa is covered by no Approve: the merge was handed no approved heads; mesh\/delivery-group is missing at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb is covered by no Approve/);
|
|
statuses[`mesh-controller@${MEMBER_HEAD}`] = "success";
|
|
statuses[`mesh-host@${HOST_HEAD}`] = "pending";
|
|
const approved = approvedHeadsOf([`novox/mesh-controller@${MEMBER_HEAD}`])!;
|
|
assert.match((await groupRefusal(forge, self, siblings, approved))!, /^mesh\/delivery-group is pending at mesh-host#114@bbbbbbbbbbbb; mesh-host#114@bbbbbbbbbbbb is not among the heads the Approve covers$/);
|
|
statuses[`mesh-host@${HOST_HEAD}`] = "success";
|
|
assert.match((await groupRefusal(forge, self, siblings, approved))!, /^mesh-host#114@bbbbbbbbbbbb is not among the heads the Approve covers$/);
|
|
approved.set("novox/mesh-host", "c".repeat(40));
|
|
assert.match((await groupRefusal(forge, self, siblings, approved))!, /^mesh-host#114@bbbbbbbbbbbb is not the head the Approve covers \(cccccccccccc\)$/);
|
|
approved.set("novox/mesh-host", HOST_HEAD.toLowerCase());
|
|
assert.equal(await groupRefusal(forge, self, siblings, approved), undefined);
|
|
// A read that fails is named, never read as a pass.
|
|
const broken = { headChecks: async () => { throw new Error("the forge is away"); } };
|
|
assert.match((await groupRefusal(broken, self, siblings, approved))!, /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, []);
|
|
});
|