Closes gitea-mcp#74 (Mathias decided 2026-10-08: gate pr_merge). Follow-up to #63/#73.
What:pr_merge no longer merges whatever it is handed. It reads the PR, the base's branch protection and the head commit's CI runs, takes the tier from the issue named in issue, and runs the SAME evaluateShipGate as tbd_ship (the CI-poll and tier-lookup helpers are now package-level and shared). Merge only for: CI fully green AND base not review-protected AND exactly one risk/low label. Everything else returns merged:false + reason + ci_status + risk and merges nothing; above risk/low a human merges in the Gitea UI.
Error vs result: an unreadable PR or branch-protection probe → error (gate cannot be evaluated, nothing merged); an unreadable issue → tier unreadable, reported, not merged; a merge conflict → error, as before.
Behaviour change for callers:pr_merge without issue, or on a PR without green CI, merges nothing. That is the point. Repos with no CI cannot be merged through this tool (same fail-closed rule as tbd_ship); merge them in the UI.
Tests: tier table (low/medium/high/none/ambiguous/unknown/case/whitespace), no issue, issue lookup 500/404/403, risk/low not overriding red/cancelled/in-progress/absent/mixed CI or a review-protected base, PR/protection read failures never merge, tier read from the named issue only, descriptor advertises the gate. Nine wiring mutants (gate bypassed, tier forced low, protection ignored, tier read from the PR number, CI not consulted, probe error swallowed, PR read error swallowed, refusal falls through to merge, risk not reported) each turn the suite red. task check exit 0.
Limits (documented in docs/HOW_GITEA_MCP_WORKS.md): the gate trusts the issue the caller names; merging in the Gitea UI and file_write_branch committing straight to main are not gated (platform-level fix = branch protection, local-dev#27 option 2).
For review: gate code (rule 8) → adversarial review by execution requested before merge. Merging triggers the production CD.
Closes gitea-mcp#74 (Mathias decided 2026-10-08: gate `pr_merge`). Follow-up to #63/#73.
**What:** `pr_merge` no longer merges whatever it is handed. It reads the PR, the base's branch protection and the head commit's CI runs, takes the tier from the issue named in `issue`, and runs the SAME `evaluateShipGate` as `tbd_ship` (the CI-poll and tier-lookup helpers are now package-level and shared). Merge only for: CI fully green AND base not review-protected AND exactly one `risk/low` label. Everything else returns `merged:false` + `reason` + `ci_status` + `risk` and merges nothing; above `risk/low` a human merges in the Gitea UI.
**Error vs result:** an unreadable PR or branch-protection probe → error (gate cannot be evaluated, nothing merged); an unreadable issue → tier `unreadable`, reported, not merged; a merge conflict → error, as before.
**Behaviour change for callers:** `pr_merge` without `issue`, or on a PR without green CI, merges nothing. That is the point. Repos with no CI cannot be merged through this tool (same fail-closed rule as `tbd_ship`); merge them in the UI.
**Tests:** tier table (low/medium/high/none/ambiguous/unknown/case/whitespace), no `issue`, issue lookup 500/404/403, risk/low not overriding red/cancelled/in-progress/absent/mixed CI or a review-protected base, PR/protection read failures never merge, tier read from the named issue only, descriptor advertises the gate. Nine wiring mutants (gate bypassed, tier forced low, protection ignored, tier read from the PR number, CI not consulted, probe error swallowed, PR read error swallowed, refusal falls through to merge, risk not reported) each turn the suite red. `task check` exit 0.
**Limits (documented in docs/HOW_GITEA_MCP_WORKS.md):** the gate trusts the issue the caller names; merging in the Gitea UI and `file_write_branch` committing straight to `main` are not gated (platform-level fix = branch protection, local-dev#27 option 2).
**For review:** gate code (rule 8) → adversarial review by execution requested before merge. Merging triggers the production CD.
---
_Created via git-mcp on behalf of @mathias_
pr_merge was an independent merge path with no CI, protection or tier check. It now reads the PR, the base's
branch protection and the head commit's CI runs, takes the tier from the issue named in `issue`, and runs the same
evaluateShipGate as tbd_ship (helpers shared, not copied). A refusal is a result (merged:false, reason, ci_status,
risk); an unreadable PR or protection probe is an error; a merge conflict is still an error. Above risk/low a human
merges in the UI. Tests: tier table, no-issue, issue lookup failures, CI and protection not overridden by risk/low,
read-side failures, tier read from the named issue only; nine wiring mutants flip-tested red. Shared reason text now
says "PR left open" (the PR already exists for pr_merge).
Behaviour change: pr_merge without `issue`, or on a PR with no green CI, merges nothing.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
The gate evaluates CI for one head sha; a push to the PR branch between that check and the merge would have merged
unchecked code. MergePRArgs now carries head_commit_id (documented in Gitea 1.27.1's MergePullRequestOption) and both
tools send the sha they evaluated. Tests for both tools; each removal flip-tested red.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
Review found a bypass: a PR reporting an empty head sha made the runs query lose its head_sha filter (the repo's 50
most recent runs of any commit were classified) and dropped head_commit_id (omitempty), so the merge was unbound.
Both tools now go through evaluateMergeGate, which refuses without a head sha, and pollRuns makes no query without
one. pr_merge also reports merged:false for a PR that is not open or is a draft (Gitea refuses closed ones itself;
the gate no longer depends on that). Tests: unit (gate, counting-server pollRuns with positive control), end to end
for both tools, CI scoped to head_sha, closed/merged/draft. The two caller-wiring mutants are equivalent to the
pollRuns guard and survive by design.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZkhi49hfeSqBp5aZJNXsM
Adversarial review verdict (gate code, local-dev rule 8): PASS at 3955758 (3 commits). Agent reviewer, not four-eyes; two rounds.
Round 1 BLOCK, fixed: a PR reporting an empty head.sha made the runs query lose its head_sha filter (the repo's 50 most recent runs of ANY commit were classified) and dropped head_commit_id (omitempty), so the merge was unbound; tbd_ship (live since v0.17.0) had the same hole. Now both tools call evaluateMergeGate, which refuses without a head commit, and pollRuns makes no query without one. Also: pr_merge returns merged:false for a PR that is not open or is a draft. Round 2 PASS, by execution: empty sha → merged:false, no runs query, no merge call; closed/merged/draft/empty-state → refused with no runs query and no merge; a normal open PR merges with head_sha=abc in the runs query and head_commit_id:"abc" in the merge body; go test ./... -race -count=1 exit 0; the doc sentence about binding the merge to the evaluated head commit is now true.
Known and accepted: the two caller-wiring mutants (callers using evaluateShipGate instead of evaluateMergeGate) survive because they are equivalent: with an empty sha pollRuns returns nil → CI none → no merge, only the reason text differs. A whitespace-only sha is not rejected (real Gitea returns no runs for it → none; the merge would be bound to that value): optional hardening, not a bypass. Not verified: Gitea's server-side refusal on a moved head, fork PRs, whether the sha is ever empty in production.
Separate bug found on the way: gitea-mcp#77 (merge_message_title is not a Gitea field).
**Adversarial review verdict (gate code, local-dev rule 8): PASS** at 3955758 (3 commits). Agent reviewer, not four-eyes; two rounds.
**Round 1 BLOCK, fixed:** a PR reporting an empty `head.sha` made the runs query lose its `head_sha` filter (the repo's 50 most recent runs of ANY commit were classified) and dropped `head_commit_id` (omitempty), so the merge was unbound; `tbd_ship` (live since v0.17.0) had the same hole. Now both tools call `evaluateMergeGate`, which refuses without a head commit, and `pollRuns` makes no query without one. Also: `pr_merge` returns `merged:false` for a PR that is not open or is a draft.
**Round 2 PASS, by execution:** empty sha → `merged:false`, no runs query, no merge call; closed/merged/draft/empty-state → refused with no runs query and no merge; a normal open PR merges with `head_sha=abc` in the runs query and `head_commit_id:"abc"` in the merge body; `go test ./... -race -count=1` exit 0; the doc sentence about binding the merge to the evaluated head commit is now true.
**Known and accepted:** the two caller-wiring mutants (callers using `evaluateShipGate` instead of `evaluateMergeGate`) survive because they are equivalent: with an empty sha `pollRuns` returns nil → CI `none` → no merge, only the reason text differs. A whitespace-only sha is not rejected (real Gitea returns no runs for it → `none`; the merge would be bound to that value): optional hardening, not a bypass. Not verified: Gitea's server-side refusal on a moved head, fork PRs, whether the sha is ever empty in production.
Separate bug found on the way: gitea-mcp#77 (`merge_message_title` is not a Gitea field).
---
_Created via git-mcp on behalf of @mathias_
mathias
merged commit d1e501cc64 into main2026-10-08 06:18:07 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes gitea-mcp#74 (Mathias decided 2026-10-08: gate
pr_merge). Follow-up to #63/#73.What:
pr_mergeno longer merges whatever it is handed. It reads the PR, the base's branch protection and the head commit's CI runs, takes the tier from the issue named inissue, and runs the SAMEevaluateShipGateastbd_ship(the CI-poll and tier-lookup helpers are now package-level and shared). Merge only for: CI fully green AND base not review-protected AND exactly onerisk/lowlabel. Everything else returnsmerged:false+reason+ci_status+riskand merges nothing; aboverisk/lowa human merges in the Gitea UI.Error vs result: an unreadable PR or branch-protection probe → error (gate cannot be evaluated, nothing merged); an unreadable issue → tier
unreadable, reported, not merged; a merge conflict → error, as before.Behaviour change for callers:
pr_mergewithoutissue, or on a PR without green CI, merges nothing. That is the point. Repos with no CI cannot be merged through this tool (same fail-closed rule astbd_ship); merge them in the UI.Tests: tier table (low/medium/high/none/ambiguous/unknown/case/whitespace), no
issue, issue lookup 500/404/403, risk/low not overriding red/cancelled/in-progress/absent/mixed CI or a review-protected base, PR/protection read failures never merge, tier read from the named issue only, descriptor advertises the gate. Nine wiring mutants (gate bypassed, tier forced low, protection ignored, tier read from the PR number, CI not consulted, probe error swallowed, PR read error swallowed, refusal falls through to merge, risk not reported) each turn the suite red.task checkexit 0.Limits (documented in docs/HOW_GITEA_MCP_WORKS.md): the gate trusts the issue the caller names; merging in the Gitea UI and
file_write_branchcommitting straight tomainare not gated (platform-level fix = branch protection, local-dev#27 option 2).For review: gate code (rule 8) → adversarial review by execution requested before merge. Merging triggers the production CD.
Created via git-mcp on behalf of @mathias
Adversarial review verdict (gate code, local-dev rule 8): PASS at
3955758(3 commits). Agent reviewer, not four-eyes; two rounds.Round 1 BLOCK, fixed: a PR reporting an empty
head.shamade the runs query lose itshead_shafilter (the repo's 50 most recent runs of ANY commit were classified) and droppedhead_commit_id(omitempty), so the merge was unbound;tbd_ship(live since v0.17.0) had the same hole. Now both tools callevaluateMergeGate, which refuses without a head commit, andpollRunsmakes no query without one. Also:pr_mergereturnsmerged:falsefor a PR that is not open or is a draft.Round 2 PASS, by execution: empty sha →
merged:false, no runs query, no merge call; closed/merged/draft/empty-state → refused with no runs query and no merge; a normal open PR merges withhead_sha=abcin the runs query andhead_commit_id:"abc"in the merge body;go test ./... -race -count=1exit 0; the doc sentence about binding the merge to the evaluated head commit is now true.Known and accepted: the two caller-wiring mutants (callers using
evaluateShipGateinstead ofevaluateMergeGate) survive because they are equivalent: with an empty shapollRunsreturns nil → CInone→ no merge, only the reason text differs. A whitespace-only sha is not rejected (real Gitea returns no runs for it →none; the merge would be bound to that value): optional hardening, not a bypass. Not verified: Gitea's server-side refusal on a moved head, fork PRs, whether the sha is ever empty in production.Separate bug found on the way: gitea-mcp#77 (
merge_message_titleis not a Gitea field).Created via git-mcp on behalf of @mathias