gitea: a merge names the head it was reviewed at (issue 381)
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-controller#197 was reviewed at 065cfa0d, force-pushed to 3ced96f
at 12:14 UTC, checked green there at 12:22, and merged at 3ced96f at
12:26. The merge tool kept its promise that only a checked head is
merged, but it took no argument for the head the review approved, so it
merged whatever the head was and a commit nobody reviewed landed.

gitea_merge_pull_request now takes `head`, the commit the caller
reviewed (a sha, or at least its first 7 characters), and refuses with
nothing sent when it is missing, is not a commit, or is not the pull
request's head — naming the head it has now. The forge is handed the
same head as head_commit_id, so a push between the read and the merge
is the forge's to refuse. The answer reads back what was merged and says
so loudly if the forge merged another head anyway.

`head` is required, not optional: an optional guard is the shape that
let this happen. A caller that leaves it out is told what to name.
This commit is contained in:
2026-10-11 01:28:52 +02:00
parent fa6360c1d3
commit c32eb6237d
2 changed files with 98 additions and 14 deletions
+33 -4
View File
@@ -262,13 +262,16 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
},
{
name: "gitea_merge_pull_request",
description: "Merge a pull request whose head has every status its base branch's protection requires as a success " +
"(mesh/merge-gate, mesh/repo-check), optionally deleting the source branch afterwards. Refused otherwise — no " +
"tool merges past a check — and only the head that was checked is merged.",
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head " +
"and every status its base branch's protection requires succeeded there (mesh/merge-gate, mesh/repo-check), " +
"optionally deleting the source branch afterwards. Refused otherwise, with nothing merged: no tool merges past " +
"a check, and none merges a head that moved after it was reviewed.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the PR number" },
head: { type: "string", description: "the commit you reviewed, as its sha or at least its first 7 characters: " +
"refused unless it is the pull request's head" },
method: { type: "string", description: "merge | rebase | squash (default merge)" },
delete_branch: { type: "boolean", description: "delete the source branch after merge (default true)" },
},
@@ -282,9 +285,27 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left
// to the forge, whose administrator — the account this tool's token is minted for — may merge past a
// rule that lets it. A rule that cannot be read is no rule passed.
// **Only the head that was reviewed** (novox/hq issue 381): mesh-controller#197 was reviewed at 065cfa0d,
// force-pushed to 3ced96f, checked green there, and merged at 3ced96f, because this tool merged whatever the
// head was and nothing told it which head the review had approved. The caller names it; a head that moved
// since is refused, and the forge is handed the same head, so a push between this read and the merge is
// refused by the forge too.
const named = args.head === undefined ? "" : String(args.head).trim().toLowerCase();
if (!named) {
throw new Refused(`${owner}/${repo}#${number} is not merged: name the head you reviewed (head), so that only ` +
`that commit can land (novox/hq issue 381). Nothing was merged`);
}
if (!isCommit(named)) {
throw new Refused(`${owner}/${repo}#${number} is not merged: head ${named} is not a commit — a sha, or at least ` +
`its first 7 characters. Nothing was merged`);
}
const pull = await gitea.getPullRequest(owner, repo, number);
if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`);
if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`);
if (!pull.head_sha.toLowerCase().startsWith(named)) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its head is ${pull.head_sha.slice(0, 12)}, not the ` +
`${named} you named — it moved after that was reviewed. Review the head it has now, then name it. Nothing was merged`);
}
const rule = await gitea.branchProtection(owner, repo, pull.base);
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
// branch nothing guards would merge past every check (novox/hq ADR 0266).
@@ -302,7 +323,15 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
// The merge commit only exists now; answered so the caller can follow what is built from it.
// pull.merged is the events entrypoint's to announce (index.ts), once, within its poll.
const merged = await gitea.getPullRequest(owner, repo, number);
return { merged: true, number, method, deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha };
const answer: Record<string, unknown> = { merged: true, number, method, head: pull.head_sha,
deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha };
// What landed, read back: the forge was handed the head, and this says so if it merged another one anyway.
if (merged.head_sha && merged.head_sha !== pull.head_sha) {
answer.landed = merged.head_sha;
answer.not_the_head_named = `the forge merged ${merged.head_sha.slice(0, 12)}, not the ${pull.head_sha.slice(0, 12)} ` +
`it was handed: a commit nobody reviewed may have landed. Read it before anything is built from it (novox/hq issue 381)`;
}
return answer;
},
},