feat: add read-only gitea_check_pr_eligibility (#14)

Add a read-only MCP tool that decides whether the current authenticated
identity + active runtime profile is eligible to review, approve,
request_changes, or merge a specific PR. Evaluation only — it never
reviews, approves, requests changes, merges, or mutates anything.

Inspects: authenticated username (/user), active profile metadata
(allowed/forbidden operations), and PR facts (author, state, head SHA,
mergeability). Returns {eligible, requested_action, authenticated_user,
profile_name, pr_author, pr_state, head_sha, mergeable, reasons}.

Fail-closed rules:
- unknown action / unknown remote -> not eligible
- action not in allowed ops, or in forbidden ops -> not eligible
- identity undetermined -> not eligible
- authenticated user == PR author -> cannot approve/merge
- PR not open -> not eligible
- merge requires a positive mergeable signal

No token/auth-header exposure. No review/approve/request-changes
mutation. No merge mutation. No multi-token switching. No
Jenkins/Ops/GlitchTip/Release/deploy behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 13:54:49 -04:00
parent 92acb36406
commit fbbbd5359e
3 changed files with 246 additions and 0 deletions
+143
View File
@@ -210,6 +210,149 @@ def gitea_view_pr(
}
# Actions whose eligibility this tool can evaluate.
_ELIGIBILITY_ACTIONS = ("review", "approve", "request_changes", "merge")
@mcp.tool()
def gitea_check_pr_eligibility(
pr_number: int,
action: str,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Read-only: is the current identity/profile eligible to perform *action*
on a PR?
Evaluates eligibility only — it NEVER reviews, approves, requests changes,
merges, or mutates anything. It inspects the authenticated identity
(via the /user endpoint), the active runtime profile metadata
(``get_profile``), and the target PR (author, state, head SHA,
mergeability), then returns a decision with clear reasons.
Fail-closed rules:
- Unknown action or unknown remote → not eligible.
- Profile has no configured allowed operations, or the action is not in
the profile's allowed operations (or is forbidden) → not eligible.
- Authenticated identity cannot be determined → not eligible.
- Authenticated user equals the PR author → not eligible to ``approve`` or
``merge``.
- PR is not open → not eligible.
- For ``merge``, PR must be reported mergeable.
Never returns the token, Authorization header, or any credential material.
Args:
pr_number: Target PR number.
action: One of 'review', 'approve', 'request_changes', 'merge'.
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict with 'eligible' (bool), the inputs inspected, and 'reasons'.
"""
action = (action or "").strip().lower()
profile = get_profile()
result = {
"eligible": False,
"requested_action": action,
"authenticated_user": None,
"profile_name": profile["profile_name"],
"allowed_operations": profile["allowed_operations"],
"pr_author": None,
"pr_number": pr_number,
"pr_state": None,
"head_sha": None,
"mergeable": None,
"remote": remote if remote in REMOTES else None,
"reasons": [],
}
reasons = result["reasons"]
if action not in _ELIGIBILITY_ACTIONS:
reasons.append(
f"unknown action '{action}'; expected one of {list(_ELIGIBILITY_ACTIONS)}"
)
return result
if remote not in REMOTES:
reasons.append(f"unknown remote '{remote}'")
return result
# Profile capability check (metadata only; not enforcement of the action).
allowed = profile["allowed_operations"]
forbidden = profile["forbidden_operations"]
if not allowed:
reasons.append("profile has no configured allowed operations (fail closed)")
if action in forbidden:
reasons.append(f"profile forbids '{action}'")
elif action not in allowed:
reasons.append(f"profile is not allowed to {action}")
h, o, r = _resolve(remote, host, org, repo)
# Authenticated identity (read-only). Fail soft; never leak error/secret.
try:
auth = _auth(h)
except Exception:
auth = None
auth_user = None
if auth:
try:
who = api_request("GET", f"https://{h}/api/v1/user", auth)
auth_user = (who or {}).get("login")
except Exception:
auth_user = None
result["authenticated_user"] = auth_user
if not auth_user:
reasons.append("authenticated identity could not be determined")
# PR facts (read-only GET; no mutation).
pr_author = None
pr_state = None
if auth:
try:
pr = api_request(
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth
)
pr_author = (pr or {}).get("user", {}).get("login")
pr_state = (pr or {}).get("state")
result["head_sha"] = ((pr or {}).get("head") or {}).get("sha")
result["mergeable"] = (pr or {}).get("mergeable")
except Exception:
reasons.append("PR details could not be retrieved")
else:
reasons.append("PR details could not be retrieved (no credentials)")
result["pr_author"] = pr_author
result["pr_state"] = pr_state
# PR must be open to act on.
if pr_state is None:
reasons.append("PR state unknown")
elif pr_state != "open":
reasons.append(f"PR is not open (state={pr_state})")
# Self-author must not approve or merge their own PR.
if auth_user and pr_author and auth_user == pr_author and action in ("approve", "merge"):
reasons.append("authenticated user is PR author")
# Merge needs a positive mergeability signal.
if action == "merge":
if result["mergeable"] is False:
reasons.append("PR is not mergeable")
elif result["mergeable"] is None:
reasons.append("PR mergeability unknown")
result["eligible"] = len(reasons) == 0
if result["eligible"]:
reasons.append("all eligibility checks passed")
return result
@mcp.tool()
def gitea_edit_pr(
pr_number: int,