A group's composed verdict names each member's siblings at the heads it composed, so a sibling's own merge is no move (issue 1049)

This commit is contained in:
2026-10-18 01:08:26 +02:00
parent 0c56f60305
commit da094008ea
6 changed files with 184 additions and 1 deletions
+50
View File
@@ -630,3 +630,53 @@ test("gitea_merge_pull_request refuses a head the Approve did not name", async (
assert.equal(state.merged, false);
assert.equal(((await merge(["novox/mesh-catalog@" + REVIEWED.toUpperCase()])) as Record<string, unknown>).merged, true);
});
// **A group approved together merges in one pass, on the real merge tool** (novox/hq ADR 0418, issue 1049): the member's
// own verdict named its sibling at an old head; the sibling got a new head, the group's composed verdict passed for both,
// the sibling merged that head, and the member then merges on its first ask: the sibling's own merge is no move of its
// base. The records are the module's own (siblings.ts), the weighing is what the sibling's merge announcement does.
test("a group of two merges in one pass: the sibling's merge of the composed head does not stop the member's merge (issue 1049)", async () => {
const { keepSiblings, siblingsFile, noteComposed, weighSiblings } = await import("../dist/siblings.js");
const both = ["novox/mesh-catalog@" + REVIEWED, "novox/mesh-host@" + SIBLING_HEAD];
const OLD_SIBLING = "9".repeat(40), HOST_BASE = "c".repeat(40), HOST_MERGE = "f".repeat(40);
const beside = `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} beside mesh-host#114@${OLD_SIBLING.slice(0, 8)}`;
state.statuses = [{ context: "mesh/merge-gate", state: "success",
description: `head ${REVIEWED.slice(0, 12)} merged into trunk ${TRUNK.slice(0, 12)} \u2014 pass: group g composed: ok` },
{ context: "mesh/repo-check", state: "success", description: `${beside} \u2014 pass: y` }];
state.groupStatus = "success";
state.siblingGroup = "success";
// The member's own verdict named the sibling at its old head.
const record: Record<string, any> = { "novox/mesh-catalog#7": { head: REVIEWED,
siblings: [{ repository: "novox/mesh-host", number: 114, head: OLD_SIBLING, base: "main", base_commit: HOST_BASE }] } };
// The composed verdict then judged both new heads.
noteComposed(record, [{ owner: "novox", repo: "mesh-catalog", number: 7, commit: REVIEWED, trunk: TRUNK.slice(0, 12), base: "main" },
{ owner: "novox", repo: "mesh-host", number: 114, commit: SIBLING_HEAD, trunk: HOST_BASE.slice(0, 12), base: "main" }]);
// The sibling merges its head; its announcement weighs the member's record. The member's gate is written only by what
// that weighing decides, through the writer.
const pulled = { number: 114, title: "", html_url: "", merged: true, state: "closed", head_sha: SIBLING_HEAD, merge_commit_sha: HOST_MERGE };
const forge = {
async getPullRequest(_o: string, repo: string, n: number) {
return repo === "mesh-host" ? pulled : { number: n, title: "", html_url: "", merged: false, state: "open", head_sha: REVIEWED };
},
async branchHead() { return HOST_MERGE; },
async commitParents() { return [HOST_BASE, SIBLING_HEAD]; },
};
const wrote: string[] = [];
const gatesOut = { async outOfDate(_o: string, _r: string, sha: string, decide: (c: any) => any[]) {
const put = decide({ "mesh/merge-gate": { state: "success", description: state.statuses[0].description },
"mesh/repo-check": { state: "success", description: "x" } });
for (const s of put) {
wrote.push(`${sha.slice(0, 12)} ${s.context} ${s.state}`);
state.statuses = [{ ...s }, ...state.statuses.filter((x) => x.context !== s.context)];
}
return put;
} };
await weighSiblings(forge as any, gatesOut as any, record, "novox/mesh-host", 114);
assert.deepEqual(wrote, [], "the member's statuses were left alone");
keepSiblings(siblingsFile(stateDir), record);
// The member merges on its first ask, with the one Approve's heads.
const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED,
approved_heads: both })) as Record<string, unknown>;
assert.equal(done.merged, true);
assert.equal(state.emitted.length, 0, "no check was asked");
});
+55
View File
@@ -268,3 +268,58 @@ test("a merged or closed member's entry is dropped, so a sibling's later push ne
assert.deepEqual(mine, {});
assert.deepEqual(own.written, []);
});
// --- the composed verdict names the siblings of each member (issue 1049) --------------------------------------
// Issue 1049: the 2026-10-17 23:48 group (mesh-controller #375 beside mesh-catalog #353). #375's own verdict named #353
// at its old head; #353 then got a new head, and the group's composed verdict passed for both new heads. The record still
// held the old head, so #353's own merge of the NEW head read as "merged otherwise than its head" and put #375's gate back
// to pending, and the group merged in two passes.
const composedMembers = () => [
{ owner: "novox", repo: "mesh-controller", number: 346, commit: MEMBER_HEAD, trunk: "111111111111", base: "main" },
{ owner: "novox", repo: "mesh-host", number: 114, commit: HOST_NEW, trunk: HOST_BASE.slice(0, 12), base: "main" },
];
const staleRecord = (): SiblingRecord => ({ "novox/mesh-controller#346": { head: MEMBER_HEAD, siblings: siblingsOf([sibling]) } });
test("without the composed verdict's siblings, a sibling's own merge of its new head is a move (the pin of issue 1049)", async () => {
const w = world();
Object.assign(w.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW });
w.parents = [HOST_BASE, HOST_NEW];
await weigh(w, staleRecord());
assert.equal(w.written.length, 2, "the record names the old head: the merge reads as another head's");
});
test("a composed verdict names the siblings of each member at the heads and base commits it composed, so a sibling's own merge of its new head is no move (issue 1049)", async () => {
const { noteComposed } = await import("../dist/siblings.js");
const record = staleRecord();
assert.equal(noteComposed(record, composedMembers()), true);
assert.deepEqual(record["novox/mesh-controller#346"], { head: MEMBER_HEAD,
siblings: [{ repository: "novox/mesh-host", number: 114, head: HOST_NEW, base: "main", base_commit: HOST_BASE.slice(0, 12) }] });
assert.deepEqual(record["novox/mesh-host#114"], { head: HOST_NEW,
siblings: [{ repository: "novox/mesh-controller", number: 346, head: MEMBER_HEAD, base: "main", base_commit: "111111111111" }] });
const w = world();
Object.assign(w.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW });
w.parents = [HOST_BASE, HOST_NEW];
await weigh(w, record);
assert.deepEqual(w.written, [], "its own merge of the head the composed check judged leaves the member's gate alone");
assert.equal(record["novox/mesh-controller#346"].siblings[0].merged, true);
// A merge of any other head, or onto any other base, is still a move.
const other = world();
const again: SiblingRecord = {};
noteComposed(again, composedMembers());
Object.assign(other.host, { merged: true, state: "closed", merge_commit_sha: MERGE, head_sha: HOST_NEW });
other.parents = [HOST_MOVED, HOST_NEW];
await weigh(other, again);
assert.equal(other.written.length, 2);
});
test("a composed verdict keeps nothing for a member it cannot name, and a group of one is none (issue 1049)", async () => {
const { noteComposed } = await import("../dist/siblings.js");
const record: SiblingRecord = {};
const [a, b] = composedMembers();
assert.equal(noteComposed(record, [a]), false);
assert.equal(noteComposed(record, [a, { ...b, number: undefined }]), false, "a member with no pull request number");
assert.equal(noteComposed(record, [a, { ...b, trunk: undefined }]), false, "a member with no trunk commit");
assert.equal(noteComposed(record, [a, { ...b, base: undefined }]), false, "a member with no base branch");
assert.deepEqual(record, {});
});