feat: add gated gitea_submit_pr_review review actions (#15)
Add gitea_submit_pr_review, the only tool that submits a Gitea PR review. It performs a review mutation (comment / approve / request_changes) only after every safety gate passes, and never merges. Gates (fail-closed at each step): 1. Validate action is comment | approve | request_changes. 2. Reuse gitea_check_pr_eligibility (#14) for authenticated-user lookup, active-profile lookup, PR-author lookup, self-approval block, and the profile-allowed-operation check. approve requires 'approve' eligibility, request_changes requires 'request_changes', comment requires 'review'. 3. Redundant self-approval block (auth user == PR author). 4. Optional expected_head_sha: refuse if the PR head has moved. 5. Only then POST /repos/{owner}/{repo}/pulls/{n}/reviews (formal review endpoint, so approvals/change-requests carry real review state). Output reports action, whether performed, authenticated user, profile name, PR author, PR number, head SHA checked, and reasons — never a token, auth header, or credential. Error text is scrubbed via _redact as defence in depth. Merge is intentionally not implemented (belongs to #16). Tests cover: self-author approve blocked, approve/request_changes/comment succeed only when eligible, unknown identity fail-closed, disallowed profile op blocked, head-SHA mismatch blocked, no mutation when gates fail, invalid action rejected, and secret redaction in output and error paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+178
@@ -353,6 +353,184 @@ def gitea_check_pr_eligibility(
|
||||
return result
|
||||
|
||||
|
||||
# Review actions this gated tool can perform, mapped to (eligibility action,
|
||||
# Gitea review *event*). The eligibility action is fed to
|
||||
# ``gitea_check_pr_eligibility`` (#14) so every mutation reuses the same
|
||||
# identity/profile/author gates. Note: 'merge' is deliberately absent — merge
|
||||
# belongs to a separate tool/issue and is never performed here.
|
||||
_REVIEW_ACTIONS = {
|
||||
# 'comment' posts review findings without an approval/rejection state.
|
||||
# #14 names this eligibility category 'review'.
|
||||
"comment": ("review", "COMMENT"),
|
||||
"approve": ("approve", "APPROVE"),
|
||||
"request_changes": ("request_changes", "REQUEST_CHANGES"),
|
||||
}
|
||||
|
||||
# Patterns scrubbed from any surfaced error text so a credential can never leak.
|
||||
_SECRET_PREFIXES = ("token ", "Basic ")
|
||||
|
||||
|
||||
def _redact(text: str) -> str:
|
||||
"""Strip anything that looks like an Authorization credential from *text*.
|
||||
|
||||
Errors raised by ``api_request`` echo the server response body, not the
|
||||
request headers, so a token should never appear — this is defence in depth
|
||||
so a future change can't leak ``token …`` / ``Basic …`` material into a
|
||||
tool result or log line.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
out = text
|
||||
for prefix in _SECRET_PREFIXES:
|
||||
idx = 0
|
||||
while True:
|
||||
i = out.find(prefix, idx)
|
||||
if i == -1:
|
||||
break
|
||||
j = i + len(prefix)
|
||||
while j < len(out) and not out[j].isspace():
|
||||
j += 1
|
||||
out = out[:i] + prefix + "[REDACTED]" + out[j:]
|
||||
idx = i + len(prefix) + len("[REDACTED]")
|
||||
return out
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_submit_pr_review(
|
||||
pr_number: int,
|
||||
action: str,
|
||||
body: str = "",
|
||||
expected_head_sha: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Gated PR review mutation: comment findings, request changes, or approve.
|
||||
|
||||
This is the only tool that submits a Gitea PR *review*. It performs a
|
||||
mutation **only after every safety gate passes**; if any gate fails it
|
||||
returns ``performed=False`` and never calls the mutating endpoint.
|
||||
|
||||
Gate order (fail-closed at each step):
|
||||
|
||||
1. Validate ``action`` is one of 'comment', 'approve', 'request_changes'.
|
||||
2. Reuse ``gitea_check_pr_eligibility`` (#14), which runs the authenticated
|
||||
-user lookup, active-profile lookup, PR-author lookup, self-approval
|
||||
block, and profile-allowed-operation check. ``approve`` requires
|
||||
eligibility for 'approve', ``request_changes`` requires
|
||||
'request_changes', and ``comment`` requires 'review'.
|
||||
3. Redundantly block self-approval (authenticated user == PR author).
|
||||
4. If ``expected_head_sha`` is supplied and the PR head has moved, abort.
|
||||
5. Only then POST the review.
|
||||
|
||||
Endpoint: ``POST /repos/{owner}/{repo}/pulls/{n}/reviews``. This is the
|
||||
*formal review* API (it records an APPROVE / COMMENT / REQUEST_CHANGES
|
||||
review state tied to the head commit), chosen over the plain issue-comment
|
||||
endpoint (``/issues/{n}/comments``) so that approvals and change requests
|
||||
carry real review state — a plain comment cannot approve or block a PR.
|
||||
|
||||
Merge is intentionally NOT implemented here.
|
||||
|
||||
Never returns the token, Authorization header, or any credential material.
|
||||
|
||||
Args:
|
||||
pr_number: Target PR number.
|
||||
action: 'comment', 'approve', or 'request_changes'.
|
||||
body: Review body / finding text.
|
||||
expected_head_sha: Optional. If given and the PR head SHA differs, the
|
||||
review is refused (guards against reviewing a changed PR).
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
|
||||
Returns:
|
||||
dict describing the attempt: action, whether it was performed, the
|
||||
authenticated user, profile name, PR author, PR number, head SHA
|
||||
checked, and the reasons/gates passed or blocked. Never secrets.
|
||||
"""
|
||||
action = (action or "").strip().lower()
|
||||
result = {
|
||||
"requested_action": action,
|
||||
"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,
|
||||
"remote": remote if remote in REMOTES else None,
|
||||
"reasons": [],
|
||||
}
|
||||
reasons = result["reasons"]
|
||||
|
||||
# Gate 1 — valid review action (no mutation on unknown action).
|
||||
if action not in _REVIEW_ACTIONS:
|
||||
reasons.append(
|
||||
f"unknown review action '{action}'; expected one of "
|
||||
f"{sorted(_REVIEW_ACTIONS)}"
|
||||
)
|
||||
return result
|
||||
eligibility_action, event = _REVIEW_ACTIONS[action]
|
||||
|
||||
# Gate 2 — reuse #14 eligibility (identity + profile + author + self-approve
|
||||
# + profile-allowed). This performs only read-only GETs.
|
||||
elig = gitea_check_pr_eligibility(
|
||||
pr_number=pr_number,
|
||||
action=eligibility_action,
|
||||
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")
|
||||
if not elig.get("eligible"):
|
||||
reasons.append(
|
||||
f"eligibility check for '{eligibility_action}' failed (fail closed)"
|
||||
)
|
||||
reasons.extend(elig.get("reasons", []))
|
||||
return result
|
||||
|
||||
# Gate 3 — redundant self-approval block (belt-and-suspenders over #14).
|
||||
auth_user = result["authenticated_user"]
|
||||
pr_author = result["pr_author"]
|
||||
if action == "approve" and auth_user and pr_author and auth_user == pr_author:
|
||||
reasons.append("self-approval blocked (authenticated user is PR author)")
|
||||
return result
|
||||
|
||||
# Gate 4 — head SHA must match if the caller pinned one.
|
||||
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:
|
||||
# Should be unreachable — eligibility fails closed without a head SHA —
|
||||
# but never submit a review without a commit to pin it to.
|
||||
reasons.append("PR head SHA unavailable (fail closed)")
|
||||
return result
|
||||
|
||||
# All gates passed — perform the single mutating call.
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
try:
|
||||
auth = _auth(h)
|
||||
review_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews"
|
||||
payload = {"body": body, "event": event, "commit_id": actual_sha}
|
||||
api_request("POST", review_url, auth, payload)
|
||||
except Exception as exc: # noqa: BLE001 — redact before surfacing
|
||||
reasons.append(f"review submission failed: {_redact(str(exc))}")
|
||||
return result
|
||||
|
||||
result["performed"] = True
|
||||
reasons.append(f"all gates passed; submitted '{event}' review on PR #{pr_number}")
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_edit_pr(
|
||||
pr_number: int,
|
||||
|
||||
Reference in New Issue
Block a user