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:
+143
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user