feat: gate gitea_merge_pr behind identity/profile/eligibility + confirmation (#16)

Replace the ungated gitea_merge_pr with a gated merge workflow. This is now
the only merge path the MCP server exposes; the merge API is called only
after every safety gate passes.

Gates (fail-closed at each step):
  1. Merge method is merge | squash | rebase.
  2. Explicit confirmation: confirmation must equal "MERGE PR <n>" (without it,
     zero API calls are made).
  3. Reuse gitea_check_pr_eligibility (#14) with action 'merge': proves the
     authenticated identity, the active profile (and that it allows merge), the
     PR author, blocks self-merge, requires the PR open, and fails closed when
     the PR is not mergeable or mergeability is unknown.
  4. Optional expected_head_sha: refuse if the PR head moved.
  5. Optional expected_changed_files: refuse if the PR's changed file set differs.
  6. Redundant self-merge block (auth user == PR author).

The force/ignore-checks option was removed — Gitea's own mergeable signal
(which reflects branch-protection required reviews/checks) must be positive,
so required approval/check state is honoured, never bypassed.

Output reports performed, authenticated user, profile name, PR author, PR
number, head SHA checked, merge method, gates passed/blocked, and merge
result / merge commit — never a token, auth header, or credential. Error text
is scrubbed via _redact.

Surface audit: no ungated merge path remains. The /merge endpoint appears only
inside gitea_merge_pr; gitea_review_pr fails closed on merge=True before any
API call; gitea_submit_pr_review has no merge parameter and 'merge' is not a
reviewable action. Tests assert all three.

Tests cover: merge succeeds only when all gates pass; self-author blocked;
unknown identity/profile blocked; profile without merge permission blocked;
missing/wrong confirmation blocked (no API call); head-SHA mismatch blocked;
changed-files mismatch blocked; closed PR blocked; non-mergeable blocked;
unknown mergeability fail-closed; no merge call when gates fail; invalid merge
method rejected; output and error redaction; and the no-ungated-merge-path audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 15:03:49 -04:00
parent cb926e25d3
commit f04cf44975
3 changed files with 462 additions and 27 deletions
+164 -18
View File
@@ -671,47 +671,193 @@ def gitea_commit_files(
}
# Merge methods supported by the Gitea merge API.
_MERGE_METHODS = ("merge", "squash", "rebase")
@mcp.tool()
def gitea_merge_pr(
pr_number: int,
confirmation: str = "",
expected_head_sha: str | None = None,
expected_changed_files: list[str] | None = None,
do: str = "merge",
title: str | None = None,
message: str | None = None,
force: bool = False,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Merge a Gitea pull request.
"""Gated merge of a Gitea pull request (#16).
This is the ONLY merge path this server exposes, and it mutates only after
every safety gate passes. No ungated merge tool remains: legacy
``gitea_review_pr`` fails closed on ``merge=True`` and
``gitea_submit_pr_review`` never merges.
Gate order (fail-closed at each step; the merge API is called only if all
gates pass):
1. Merge method (``do``) is 'merge', 'squash', or 'rebase'.
2. Explicit confirmation: ``confirmation`` must equal ``"MERGE PR <n>"``.
Without it, the tool makes no API calls at all.
3. Reuse ``gitea_check_pr_eligibility`` (#14) with action 'merge': this
proves the authenticated identity, the active profile (and that it
allows merge), the PR author, blocks self-merge, requires the PR to be
open, and fails closed when the PR is not mergeable or mergeability is
unknown.
4. If ``expected_head_sha`` is given and the PR head moved → refuse.
5. If ``expected_changed_files`` is given and the PR's changed file set
differs → refuse.
6. Redundant self-merge block (authenticated user == PR author).
No force / ignore-checks option is exposed. Gitea's own ``mergeable`` signal
(which reflects branch-protection required reviews and status checks) must
be positive, so required approval/check state is honoured, never bypassed.
Never returns the token, Authorization header, or any credential material.
Args:
pr_number: The PR number to merge.
confirmation: Must be exactly ``"MERGE PR <pr_number>"`` or merge is refused.
expected_head_sha: Strongly recommended. If set and the PR head differs, refuse.
expected_changed_files: Optional. If set and the PR's changed file set
differs, refuse.
do: Merge style — 'merge', 'squash', or 'rebase'.
title: Optional merge title.
message: Optional merge message.
force: Force merge, ignoring status checks.
title: Optional merge commit title.
message: Optional merge commit message.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'success' and 'message'.
dict describing the attempt: performed, authenticated user, profile
name, PR author, PR number, head SHA checked, merge method,
reasons/gates passed or blocked, and merge result / merge commit if
available. Never secrets.
"""
h, o, r = _resolve(remote, host, org, repo)
auth = _auth(h)
url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/merge"
payload = {
"Do": do,
"force_merge": force,
do = (do or "").strip().lower()
result = {
"performed": False,
"authenticated_user": None,
"profile_name": get_profile()["profile_name"],
"pr_author": None,
"pr_number": pr_number,
"head_sha": None,
"expected_head_sha": expected_head_sha,
"merge_method": do,
"mergeable": None,
"remote": remote if remote in REMOTES else None,
"merge_result": None,
"merge_commit": None,
"reasons": [],
}
if title:
payload["MergeTitleField"] = title
if message:
payload["MergeMessageField"] = message
api_request("POST", url, auth, payload)
return {"success": True, "message": f"PR #{pr_number} merged via '{do}'."}
reasons = result["reasons"]
# Gate 1 — valid merge method (no API call on a bad method).
if do not in _MERGE_METHODS:
reasons.append(
f"unknown merge method '{do}'; expected one of {list(_MERGE_METHODS)}"
)
return result
# Gate 2 — explicit confirmation (fail fast; zero API calls without it).
expected_confirmation = f"MERGE PR {pr_number}"
if (confirmation or "").strip() != expected_confirmation:
reasons.append(
f"explicit confirmation required: pass confirmation='{expected_confirmation}'"
)
return result
# Gate 3 — reuse #14 eligibility (identity + profile + merge-allowed +
# author + self-merge block + open + mergeable/unknown fail-closed).
# Read-only GETs only.
elig = gitea_check_pr_eligibility(
pr_number=pr_number, action="merge", remote=remote,
host=host, org=org, repo=repo,
)
result["authenticated_user"] = elig.get("authenticated_user")
result["profile_name"] = elig.get("profile_name", result["profile_name"])
result["pr_author"] = elig.get("pr_author")
result["head_sha"] = elig.get("head_sha")
result["mergeable"] = elig.get("mergeable")
if not elig.get("eligible"):
reasons.append("eligibility check for 'merge' failed (fail closed)")
reasons.extend(elig.get("reasons", []))
return result
# Gate 4 — head SHA must match if the caller pinned a reviewed SHA.
actual_sha = result["head_sha"]
if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
reasons.append(
"expected head SHA does not match current PR head (fail closed)"
)
return result
if not actual_sha:
# Unreachable — eligibility fails closed without a head SHA — but never
# merge a PR whose head commit we could not read.
reasons.append("PR head SHA unavailable (fail closed)")
return result
h, o, r = _resolve(remote, host, org, repo)
# Gate 5 — changed files must match the reviewed set, if provided.
if expected_changed_files is not None:
try:
auth = _auth(h)
files = api_request(
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}/files", auth
)
actual_files = sorted(
(f or {}).get("filename", "") for f in (files or [])
)
except Exception as exc: # noqa: BLE001 — redact before surfacing
reasons.append(
f"could not verify changed files (fail closed): {_redact(str(exc))}"
)
return result
result["changed_files"] = actual_files
if actual_files != sorted(expected_changed_files):
reasons.append(
"PR changed files do not match expected_changed_files (fail closed)"
)
return result
# Gate 6 — redundant self-merge block (belt-and-suspenders over #14).
auth_user = result["authenticated_user"]
pr_author = result["pr_author"]
if auth_user and pr_author and auth_user == pr_author:
reasons.append("self-merge blocked (authenticated user is PR author)")
return result
# All gates passed — perform the single merge mutation.
try:
auth = _auth(h)
merge_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/merge"
payload = {"Do": do}
if title:
payload["MergeTitleField"] = title
if message:
payload["MergeMessageField"] = message
api_request("POST", merge_url, auth, payload)
# Best-effort read-back of the merge commit SHA (redacted on error).
try:
merged = api_request(
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
)
result["merge_commit"] = (merged or {}).get("merged_commit_sha")
except Exception:
result["merge_commit"] = None
except Exception as exc: # noqa: BLE001 — redact before surfacing
reasons.append(f"merge failed: {_redact(str(exc))}")
return result
result["performed"] = True
result["merge_result"] = f"PR #{pr_number} merged via '{do}'."
reasons.append(f"all gates passed; merged PR #{pr_number} via '{do}'")
return result
@mcp.tool()