Allow read-only PR queue inventory under author profiles while preserving hard review/merge gates
This commit is contained in:
+171
-2
@@ -555,6 +555,41 @@ def gitea_check_pr_eligibility(
|
||||
else:
|
||||
reasons.append(f"profile is not allowed to {action}")
|
||||
|
||||
# Stop here immediately if the action implies reviewer role and the
|
||||
# profile doesn't permit it. No identity/PR API calls on this path;
|
||||
# the structured #142 report is built config-only (no network).
|
||||
if action in ("review", "approve", "request_changes", "merge"):
|
||||
result["eligible"] = False
|
||||
missing_perm = (
|
||||
f"gitea.pr.{action}"
|
||||
if action in ("approve", "request_changes", "merge", "review")
|
||||
else f"gitea.{action}")
|
||||
result["missing_permission"] = missing_perm
|
||||
result["active_profile"] = profile["profile_name"]
|
||||
result["active_identity"] = None
|
||||
result["permission_report"] = _permission_block_report(
|
||||
missing_perm)
|
||||
|
||||
req_profile = None
|
||||
if action in ("approve", "request_changes", "review"):
|
||||
req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)"
|
||||
elif action == "merge":
|
||||
req_profile = "A profile with reviewer role permissions and explicit merge permission"
|
||||
result["required_profile"] = req_profile
|
||||
|
||||
switching_supported = gitea_config.is_runtime_switching_enabled()
|
||||
if switching_supported:
|
||||
result["fixable_by_profile_switch"] = True
|
||||
result["requires_different_namespace"] = False
|
||||
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
|
||||
else:
|
||||
result["fixable_by_profile_switch"] = False
|
||||
result["requires_different_namespace"] = True
|
||||
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
|
||||
|
||||
result["safe_next_step"] = safe_step
|
||||
return result
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
|
||||
# Authenticated identity (read-only). Fail soft; never leak error/secret.
|
||||
@@ -1304,6 +1339,131 @@ def gitea_review_pr(
|
||||
}
|
||||
action = event_map[event]
|
||||
|
||||
# --- Phase 1: Read-only PR queue inventory ---
|
||||
profile = get_profile()
|
||||
allowed = profile.get("allowed_operations") or []
|
||||
forbidden = profile.get("forbidden_operations") or []
|
||||
switching_supported = gitea_config.is_runtime_switching_enabled()
|
||||
|
||||
can_review, _ = gitea_config.check_operation("gitea.pr.review", allowed, forbidden)
|
||||
can_approve, _ = gitea_config.check_operation("gitea.pr.approve", allowed, forbidden)
|
||||
can_merge, _ = gitea_config.check_operation("gitea.pr.merge", allowed, forbidden)
|
||||
can_read, _ = gitea_config.check_operation("gitea.read", allowed, forbidden)
|
||||
|
||||
is_reviewer = can_review or can_approve or can_merge
|
||||
|
||||
inventory_attempted = False
|
||||
prs_found_count = 0
|
||||
pr_details_list = []
|
||||
inventory_msg = ""
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = None
|
||||
try:
|
||||
auth = _auth(h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
auth_user = None
|
||||
if auth:
|
||||
try:
|
||||
who = api_request("GET", gitea_url(h, "/api/v1/user"), auth)
|
||||
auth_user = (who or {}).get("login")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if can_read and auth:
|
||||
inventory_attempted = True
|
||||
try:
|
||||
url = f"{repo_api_url(h, o, r)}/pulls?state=open"
|
||||
prs = api_get_all(url, auth)
|
||||
prs_found_count = len(prs)
|
||||
for pr in prs:
|
||||
labels = [l["name"] for l in pr.get("labels", [])]
|
||||
body_text = pr.get("body") or ""
|
||||
linked = []
|
||||
# Simple extraction of linked issues from body
|
||||
for match in re.findall(r'#(\d+)', body_text):
|
||||
linked.append(f"#{match}")
|
||||
|
||||
pr_head_sha = (pr.get("head") or {}).get("sha")
|
||||
pr_author = (pr.get("user") or {}).get("login")
|
||||
|
||||
# Determine review block status
|
||||
req_non_author = False
|
||||
if pr_author and auth_user and pr_author == auth_user:
|
||||
req_non_author = True
|
||||
|
||||
pr_details_list.append({
|
||||
"number": pr["number"],
|
||||
"title": pr["title"],
|
||||
"author": pr_author,
|
||||
"base": pr["base"]["ref"],
|
||||
"head": pr["head"]["ref"],
|
||||
"mergeable": pr.get("mergeable"),
|
||||
"linked_issues": list(set(linked)),
|
||||
"labels": labels,
|
||||
"head_sha": pr_head_sha,
|
||||
"requires_non_author_reviewer": req_non_author
|
||||
})
|
||||
except Exception as e:
|
||||
inventory_msg = f"PR inventory failed: {str(e)}"
|
||||
else:
|
||||
inventory_msg = "PR inventory not attempted"
|
||||
|
||||
# Build inventory report string
|
||||
report_lines = []
|
||||
if inventory_attempted:
|
||||
if pr_details_list:
|
||||
report_lines.append(f"Open PRs found: {prs_found_count}")
|
||||
for item in pr_details_list:
|
||||
status_str = "Review/merge blocked (Current profile is Author)"
|
||||
if item["requires_non_author_reviewer"]:
|
||||
status_str = "Review/merge blocked (Self-author). Requires a genuine non-author reviewer"
|
||||
elif is_reviewer:
|
||||
status_str = "Review/merge eligible under current profile"
|
||||
|
||||
report_lines.append(
|
||||
f"- PR #{item['number']}: {item['title']}\n"
|
||||
f" Author: {item['author']}\n"
|
||||
f" Base/Head: {item['base']} / {item['head']}\n"
|
||||
f" Head SHA: {item['head_sha']}\n"
|
||||
f" Mergeability: {item['mergeable']}\n"
|
||||
f" Linked Issues: {item['linked_issues']}\n"
|
||||
f" Labels: {item['labels']}\n"
|
||||
f" Status: {status_str}"
|
||||
)
|
||||
else:
|
||||
if inventory_msg:
|
||||
report_lines.append(inventory_msg)
|
||||
else:
|
||||
report_lines.append("Open PRs found: 0")
|
||||
else:
|
||||
report_lines.append(inventory_msg)
|
||||
|
||||
inventory_report = "\n".join(report_lines)
|
||||
|
||||
# If the current profile is not a reviewer, or lacks necessary permissions for review action
|
||||
if not is_reviewer:
|
||||
# Determine the safe next step based on static vs dynamic profile switching support
|
||||
if switching_supported:
|
||||
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
|
||||
else:
|
||||
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
|
||||
|
||||
blocked_message = (
|
||||
f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
|
||||
f"=== Review/Merge Blocked ===\n"
|
||||
f"The active profile '{profile['profile_name']}' is an Author profile and does not have review or merge permissions.\n"
|
||||
f"Safe next step: {safe_step}"
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"message": blocked_message
|
||||
}
|
||||
|
||||
# --- Phase 2: Reviewer-only review/merge actions ---
|
||||
# Since it is a reviewer profile, we proceed to submit the review
|
||||
result = gitea_submit_pr_review(
|
||||
pr_number=pr_number,
|
||||
action=action,
|
||||
@@ -1315,11 +1475,20 @@ def gitea_review_pr(
|
||||
repo=repo
|
||||
)
|
||||
|
||||
# Include the inventory report in the response message
|
||||
if result.get("performed"):
|
||||
return {"success": True, "message": f"Successfully submitted review for PR #{pr_number} with event '{event}'."}
|
||||
msg = (
|
||||
f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
|
||||
f"Successfully submitted review for PR #{pr_number} with event '{event}'."
|
||||
)
|
||||
return {"success": True, "message": msg}
|
||||
else:
|
||||
reasons = result.get("reasons", [])
|
||||
out = {"success": False, "message": f"Review submission failed eligibility gates: {reasons}"}
|
||||
msg = (
|
||||
f"=== PR Queue Inventory ===\n{inventory_report}\n\n"
|
||||
f"Review submission failed eligibility gates: {reasons}"
|
||||
)
|
||||
out = {"success": False, "message": msg}
|
||||
if result.get("permission_report"):
|
||||
out["permission_report"] = result["permission_report"]
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user