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/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:
@@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user