gitea: twelve characters name a head, and what landed is read from the merge commit (review of issue 381)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
The review of #193 approved it with five nits, all taken. Seven characters of a sha can be matched by a commit made to match them in seconds, so an author who force-pushes could have a review merge a commit nobody read. A head is now named by at least 12 characters, up to 64 for a sha-256 repository. What landed was read from the forge's record of the pull request, which is the very thing a race leaves behind. It is now read from the merge commit: its second parent for a merge, the commit itself for a fast-forward. A squash or a rebase makes new commits that name no head, and the answer says it cannot be read back instead of staying silent. A merge of another head than the one handed over was reported as a field beside merged: true, which a caller reading only that would miss. It is now an error that says the merge happened and what landed. The refusal no longer asserts that the head moved after the review, which it cannot know; it says the head is not the one named.
This commit is contained in:
@@ -21,10 +21,13 @@ const state = {
|
||||
statuses: [] as { context: string; state: string }[],
|
||||
rules: [] as any[],
|
||||
lastCommit: "2026-01-01T00:00:00Z",
|
||||
// The pull request's head, and the head the forge reports once merged (the same, unless a test says otherwise).
|
||||
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
|
||||
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
|
||||
// merge commit's own sha.
|
||||
head: "",
|
||||
landed: "",
|
||||
merged: false,
|
||||
mergeCommit: "",
|
||||
};
|
||||
const REVIEWED = "abc1234def5678901234567890abcdef12345678";
|
||||
const protectedMain = () => ({ rule_name: "main", enable_push: false, enable_status_check: true,
|
||||
@@ -38,6 +41,7 @@ beforeEach(() => {
|
||||
state.head = REVIEWED;
|
||||
state.landed = "";
|
||||
state.merged = false;
|
||||
state.mergeCommit = "feedface00000000000000000000000000000000";
|
||||
});
|
||||
|
||||
const json = (res: ServerResponse, status: number, body: unknown): void => {
|
||||
@@ -67,10 +71,13 @@ const server = createServer(async (req, res) => {
|
||||
return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]);
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/pulls/7`) {
|
||||
const sha = state.merged && state.landed ? state.landed : state.head;
|
||||
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : "open", merged: state.merged,
|
||||
head: { ref: "feat/x", sha }, base: { ref: "main" }, html_url: "http://fake/7",
|
||||
merge_commit_sha: state.merged ? "feedface00000000000000000000000000000000" : null });
|
||||
head: { ref: "feat/x", sha: state.head }, base: { ref: "main" }, html_url: "http://fake/7",
|
||||
merge_commit_sha: state.merged ? state.mergeCommit : null });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) {
|
||||
return json(res, 200, { sha: state.mergeCommit,
|
||||
parents: [{ sha: "8a53532d89b20f0fe4fd0fbc814f2e86b61cbc6b" }, { sha: state.landed || state.head }] });
|
||||
}
|
||||
if (method === "GET" && path === `${repo}/commits/${state.head}/status`) {
|
||||
return json(res, 200, { sha: state.head, state: "success", statuses: state.statuses });
|
||||
@@ -166,42 +173,59 @@ test("gitea_merge_pull_request merges only a head whose required statuses all su
|
||||
|
||||
// A merge lands only the head that was reviewed (novox/hq issue 381): mesh-controller#197 was reviewed at one head,
|
||||
// force-pushed, checked green at the new one, and merged there, because the tool merged whatever the head was.
|
||||
test("gitea_merge_pull_request merges only the head it is named, and refuses one that moved after the review", async () => {
|
||||
test("gitea_merge_pull_request merges only the head it is named, and refuses one that is not the head", async () => {
|
||||
const merge = (head?: string) => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
...(head === undefined ? {} : { head }) });
|
||||
|
||||
// No head named: there is no review to hold the merge to.
|
||||
await assert.rejects(merge(), /name the head you reviewed/);
|
||||
await assert.rejects(merge(" "), /name the head you reviewed/);
|
||||
await assert.rejects(merge("main"), /is not a commit/);
|
||||
await assert.rejects(merge("abc12"), /is not a commit/); // fewer than 7 characters names too many commits
|
||||
await assert.rejects(merge("main"), /does not name one commit/);
|
||||
// Seven characters can be matched by a commit made to match them: twelve are asked for.
|
||||
await assert.rejects(merge(REVIEWED.slice(0, 7)), /does not name one commit — give its sha, or at least its first 12/);
|
||||
await assert.rejects(merge(REVIEWED.slice(0, 11)), /does not name one commit/);
|
||||
|
||||
// The head moved after the review, and its checks passed where it is now: the case of mesh-controller#197.
|
||||
state.head = "3ced96fc6f8ad6cc7b93698912cb08a71635158a";
|
||||
await assert.rejects(merge(REVIEWED), /its head is 3ced96fc6f8a, not the abc1234def5678901234567890abcdef12345678 you named/);
|
||||
await assert.rejects(merge(REVIEWED.slice(0, 7)), /not the abc1234 you named/);
|
||||
await assert.rejects(merge(REVIEWED.slice(0, 12)), /not the abc1234def56 you named\. If you reviewed an earlier head/);
|
||||
assert.deepEqual(writes(), [], "a refused merge sends nothing");
|
||||
|
||||
// Named as reviewed, by its first 7 characters, in either case: the forge is handed the whole head.
|
||||
// Named as reviewed, by its first 12 characters, in either case: the forge is handed the whole head, and the merge
|
||||
// commit's second parent says that head landed.
|
||||
state.head = REVIEWED;
|
||||
const done = (await merge(REVIEWED.slice(0, 7).toUpperCase())) as Record<string, unknown>;
|
||||
const done = (await merge(REVIEWED.slice(0, 12).toUpperCase())) as Record<string, unknown>;
|
||||
const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!;
|
||||
assert.equal(sent.body.head_commit_id, REVIEWED);
|
||||
assert.equal(done.head, REVIEWED);
|
||||
assert.equal(done.landed, undefined);
|
||||
assert.equal(done.not_the_head_named, undefined);
|
||||
assert.equal(done.landed, REVIEWED);
|
||||
assert.equal(done.read_back, undefined);
|
||||
});
|
||||
|
||||
// The forge is handed the head, and the answer reads back what it merged: if it merged another one anyway, the
|
||||
// answer says so rather than reporting the merge as the one asked for.
|
||||
test("gitea_merge_pull_request says so when the forge merged a head other than the one it was handed", async () => {
|
||||
// The forge is handed the head, and what landed is read from the merge commit, not from the forge's record of the
|
||||
// pull request (which here still says the head it was handed). Another head landing is an error that says the merge
|
||||
// happened, never a field beside merged: true.
|
||||
test("gitea_merge_pull_request is loud when the forge merged a head other than the one it was handed", async () => {
|
||||
state.landed = "3ced96fc6f8ad6cc7b93698912cb08a71635158a";
|
||||
const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED })) as Record<string, unknown>;
|
||||
assert.equal(done.merged, true);
|
||||
assert.equal(done.head, REVIEWED);
|
||||
assert.equal(done.landed, "3ced96fc6f8ad6cc7b93698912cb08a71635158a");
|
||||
assert.match(String(done.not_the_head_named), /merged 3ced96fc6f8a, not the abc1234def56/);
|
||||
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED }), /WAS merged \(feedface0000.*\), but the forge merged 3ced96fc6f8a, not the abc1234def56 it was handed/);
|
||||
assert.equal(state.merged, true, "the merge happened: the error is what says so");
|
||||
});
|
||||
|
||||
test("gitea_merge_pull_request reads back a fast-forward, and says it cannot read back a squash or a rebase", async () => {
|
||||
state.mergeCommit = REVIEWED; // a fast-forward lands the head itself
|
||||
const ff = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED, method: "fast-forward-only" })) as Record<string, unknown>;
|
||||
assert.equal(ff.landed, REVIEWED);
|
||||
|
||||
for (const method of ["squash", "rebase", "rebase-merge"]) {
|
||||
state.merged = false;
|
||||
state.mergeCommit = "feedface00000000000000000000000000000000";
|
||||
const done = (await tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
|
||||
head: REVIEWED, method })) as Record<string, unknown>;
|
||||
assert.equal(done.landed, undefined, method);
|
||||
assert.match(String(done.read_back), new RegExp(`a ${method} makes new commits that name no head`));
|
||||
}
|
||||
});
|
||||
|
||||
test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => {
|
||||
|
||||
Reference in New Issue
Block a user