fix(mcp): require visible APPROVED reviews before merge (closes #244)
Gitea expects review event APPROVED, not APPROVE; the wrong event left submissions as PENDING drafts. Verify terminal verdicts after submit, submit pending drafts when needed, and block merge when approval_visible is false.
This commit is contained in:
+102
-2
@@ -1180,7 +1180,8 @@ _REVIEW_ACTIONS = {
|
||||
# 'comment' posts review findings without an approval/rejection state.
|
||||
# #14 names this eligibility category 'review'.
|
||||
"comment": ("review", "COMMENT"),
|
||||
"approve": ("approve", "APPROVE"),
|
||||
# Gitea ReviewStateType uses APPROVED, not APPROVE — wrong event leaves PENDING (#244).
|
||||
"approve": ("approve", "APPROVED"),
|
||||
"request_changes": ("request_changes", "REQUEST_CHANGES"),
|
||||
}
|
||||
|
||||
@@ -1390,6 +1391,42 @@ def _redact(text: str) -> str:
|
||||
# neither may drive the blocking/approval summaries.
|
||||
_VERDICT_STATES = ("APPROVED", "REQUEST_CHANGES", "COMMENT")
|
||||
|
||||
# Terminal review events that must be visible after live submission (#244).
|
||||
_SUBMIT_VISIBLE_EVENTS = frozenset({"APPROVED", "REQUEST_CHANGES"})
|
||||
|
||||
|
||||
def _latest_review_state_for_reviewer(raw_reviews: list, reviewer: str) -> str | None:
|
||||
"""Return the latest non-COMMENT verdict for *reviewer*, or None."""
|
||||
ordered = sorted(
|
||||
raw_reviews or [],
|
||||
key=lambda rv: ((rv.get("submitted_at") or ""), rv.get("id") or 0),
|
||||
)
|
||||
latest = None
|
||||
for rv in ordered:
|
||||
state = (rv.get("state") or "").upper()
|
||||
login = (rv.get("user") or {}).get("login", "")
|
||||
if login != reviewer or state not in _VERDICT_STATES or state == "COMMENT":
|
||||
continue
|
||||
latest = state
|
||||
return latest
|
||||
|
||||
|
||||
def _submit_pending_pull_review(
|
||||
h: str,
|
||||
o: str,
|
||||
r: str,
|
||||
pr_number: int,
|
||||
review_id: int,
|
||||
event: str,
|
||||
body: str,
|
||||
auth,
|
||||
) -> dict | None:
|
||||
"""Submit a PENDING draft review via Gitea's pending-review endpoint."""
|
||||
submit_url = (
|
||||
f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews/{review_id}"
|
||||
)
|
||||
return api_request("POST", submit_url, auth, {"body": body, "event": event})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_get_pr_review_feedback(
|
||||
@@ -1630,6 +1667,7 @@ def _evaluate_pr_review_submission(
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
review_id = None
|
||||
submitted_state = None
|
||||
try:
|
||||
auth = _auth(h)
|
||||
review_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews"
|
||||
@@ -1637,10 +1675,43 @@ def _evaluate_pr_review_submission(
|
||||
resp = api_request("POST", review_url, auth, payload)
|
||||
if isinstance(resp, dict):
|
||||
review_id = resp.get("id")
|
||||
submitted_state = (resp.get("state") or "").upper() or None
|
||||
if (
|
||||
submitted_state == "PENDING"
|
||||
and review_id
|
||||
and event in _SUBMIT_VISIBLE_EVENTS
|
||||
):
|
||||
resp = _submit_pending_pull_review(
|
||||
h, o, r, pr_number, review_id, event, body, auth,
|
||||
)
|
||||
if isinstance(resp, dict):
|
||||
submitted_state = (resp.get("state") or "").upper() or None
|
||||
except Exception as exc: # noqa: BLE001 — redact before surfacing
|
||||
reasons.append(f"review submission failed: {_redact(str(exc))}")
|
||||
return result
|
||||
|
||||
if action in _TERMINAL_REVIEW_ACTIONS:
|
||||
try:
|
||||
auth = _auth(h)
|
||||
raw_reviews = (
|
||||
api_request("GET", f"{review_url}", auth) or []
|
||||
)
|
||||
visible = _latest_review_state_for_reviewer(raw_reviews, auth_user)
|
||||
except Exception as exc: # noqa: BLE001 — redact before surfacing
|
||||
reasons.append(
|
||||
f"could not verify submitted review verdict (fail closed): "
|
||||
f"{_redact(str(exc))}"
|
||||
)
|
||||
return result
|
||||
result["submitted_verdict"] = visible
|
||||
result["review_verdict_visible"] = visible == event
|
||||
if visible != event:
|
||||
reasons.append(
|
||||
f"review submission left verdict '{submitted_state or visible or 'PENDING'}'; "
|
||||
f"expected visible '{event}' for reviewer '{auth_user}' (fail closed)"
|
||||
)
|
||||
return result
|
||||
|
||||
record_live_review_mutation(pr_number, action, review_id)
|
||||
result["performed"] = True
|
||||
reasons.append(f"all gates passed; submitted '{event}' review on PR #{pr_number}")
|
||||
@@ -2095,6 +2166,9 @@ def gitea_merge_pr(
|
||||
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).
|
||||
7. Re-read formal review feedback (#167): refuse when
|
||||
``approval_visible`` is false or undismissed REQUEST_CHANGES block
|
||||
merge — PENDING draft approvals do not count (#244).
|
||||
|
||||
No force / ignore-checks option is exposed. Gitea's own ``mergeable`` signal
|
||||
(which reflects branch-protection required reviews and status checks) must
|
||||
@@ -2219,7 +2293,33 @@ def gitea_merge_pr(
|
||||
reasons.append("self-merge blocked (authenticated user is PR author)")
|
||||
return result
|
||||
|
||||
# Gate 7 — in-process mutation authority (#199): the last check before
|
||||
# Gate 7 — visible formal approval required (#244). PENDING drafts and
|
||||
# absent verdicts do not satisfy this gate even when Gitea mergeable is true.
|
||||
feedback = gitea_get_pr_review_feedback(
|
||||
pr_number=pr_number, remote=remote, host=host, org=org, repo=repo,
|
||||
)
|
||||
if not feedback.get("success"):
|
||||
reasons.append("PR review feedback unavailable before merge (fail closed)")
|
||||
reasons.extend(feedback.get("reasons", []))
|
||||
if feedback.get("permission_report"):
|
||||
result["permission_report"] = feedback["permission_report"]
|
||||
return result
|
||||
result["approval_visible"] = feedback.get("approval_visible")
|
||||
result["has_blocking_change_requests"] = feedback.get(
|
||||
"has_blocking_change_requests")
|
||||
if feedback.get("has_blocking_change_requests"):
|
||||
reasons.append(
|
||||
"undismissed REQUEST_CHANGES review blocks merge (fail closed)"
|
||||
)
|
||||
return result
|
||||
if not feedback.get("approval_visible"):
|
||||
reasons.append(
|
||||
"no visible APPROVED review on PR; verify review submission "
|
||||
"completed before merge (fail closed)"
|
||||
)
|
||||
return result
|
||||
|
||||
# Gate 8 — in-process mutation authority (#199): the last check before
|
||||
# the merge mutation, using the identity the eligibility gate proved.
|
||||
# A profile/identity flip or side-channel override between preflight
|
||||
# and merge fails closed here.
|
||||
|
||||
Reference in New Issue
Block a user