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