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:
+164
-18
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user