feat: enforce session hard-stop after terminal review mutation (#332)

Extends the single-terminal-decision machinery (#211) to session-level
hard-stop semantics:

- terminal_review_hard_stop_reasons: after a live terminal verdict, only
  the merge sequence for the same approved PR may continue; REQUEST_CHANGES
  stops the run; operator-approved corrections re-open the review path
  only, never a cross-PR merge.
- gitea_merge_pr: new Gate 2b consults the hard-stop before any API call,
  so a run can never verdict one PR and merge another.
- gitea_mark_final_review_decision: hard-stop check with explicit stop
  guidance naming the consumed terminal mutation; plus duplicate
  REQUEST_CHANGES suppression — an unresolved request-changes at the
  current head SHA refuses a second request-changes (fail closed when
  feedback cannot be verified).
- tests/test_terminal_review_hard_stop.py: 13 tests covering the
  acceptance criteria; existing TestSubmitPrReview setups stub the new
  feedback fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-07 04:32:15 -04:00
co-authored by Claude Opus 4.8
parent fd396df334
commit 456e46d3fc
3 changed files with 288 additions and 3 deletions
+83
View File
@@ -1677,6 +1677,51 @@ def record_live_review_mutation(pr_number: int, action: str, review_id: int | No
_save_review_decision_lock(lock)
def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[str]:
"""Session hard-stop after a terminal live review mutation (#332).
After a terminal verdict is consumed in this run, the only permitted
continuation is the merge sequence for the same PR that was approved.
A REQUEST_CHANGES (or an approval of a different PR) blocks every
further review/mark-ready/merge mutation. An operator-approved
correction (#211) re-opens the review path only — never a cross-PR
merge.
*operation* is one of 'merge', 'mark_ready', or 'review'.
Returns [] when the operation may proceed.
"""
lock = _load_review_decision_lock()
if lock is None:
return []
terminals = [
m for m in (lock.get("live_mutations") or [])
if m.get("action") in _TERMINAL_REVIEW_ACTIONS
]
if not terminals:
return []
last = terminals[-1]
if (
operation == "merge"
and last.get("action") == "approve"
and last.get("pr_number") == pr_number
):
return []
if operation in ("mark_ready", "review") and lock.get("correction_authorized"):
return []
if last.get("action") == "approve":
guidance = (
f"only the merge sequence for approved PR "
f"#{last.get('pr_number')} may continue"
)
else:
guidance = "the session must stop and produce a final report"
return [
"terminal review mutation already consumed in this run "
f"({last.get('action')} on PR #{last.get('pr_number')}); {guidance} "
"(fail closed, #332)"
]
# Patterns scrubbed from any surfaced error text so a credential can never leak.
_SECRET_PREFIXES = ("token ", "Basic ")
@@ -2092,6 +2137,9 @@ def gitea_mark_final_review_decision(
}
org = resolved_org
repo = resolved_repo
hard_stop = terminal_review_hard_stop_reasons(pr_number, "mark_ready")
if hard_stop:
return {"marked_ready": False, "reasons": hard_stop}
if lock.get("live_mutations") and not lock.get("correction_authorized"):
return {
"marked_ready": False,
@@ -2109,6 +2157,33 @@ def gitea_mark_final_review_decision(
f"{sorted(_REVIEW_ACTIONS)}"
],
}
if action == "request_changes":
# Duplicate request-changes suppression (#332): an unresolved
# REQUEST_CHANGES at the current head must not be duplicated.
feedback = gitea_get_pr_review_feedback(
pr_number, remote=remote, org=org, repo=repo)
if not feedback.get("success"):
return {
"marked_ready": False,
"reasons": [
"could not verify existing request-changes state for "
f"PR #{pr_number}; refusing to submit a possibly "
"duplicate REQUEST_CHANGES (fail closed, #332)"
],
}
if (
feedback.get("has_blocking_change_requests")
and not feedback.get("review_feedback_stale")
):
return {
"marked_ready": False,
"reasons": [
f"PR #{pr_number} already has an unresolved "
"REQUEST_CHANGES at the current head SHA; do not "
"duplicate the request-changes review (fail closed, "
"#332)"
],
}
lock["final_review_decision_ready"] = True
lock["ready_pr_number"] = pr_number
lock["ready_action"] = action
@@ -2678,6 +2753,14 @@ def gitea_merge_pr(
)
return result
# Gate 2b — session hard-stop after a terminal review mutation (#332):
# merge may only continue for the same PR that was just approved.
# Local in-process check; zero API calls.
hard_stop = terminal_review_hard_stop_reasons(pr_number, "merge")
if hard_stop:
reasons.extend(hard_stop)
return result
# Gate 3 — reuse #14 eligibility (identity + profile + merge-allowed +
# author + self-merge block + open + mergeable/unknown fail-closed).
# Read-only GETs only.