Prior REQUEST_CHANGES on head A no longer blocks formal re-review on head B of the same open PR. Locks store reviewed head SHA, hard-stop and mark_ready/submit gates allow a fresh decision boundary when the live head differs, and same-head duplicates remain fail-closed. Open-PR lock cleanup stays forbidden (#594); assessment reports locked vs current head and stale_by_head / fresh_review_on_current_head_allowed. Closes #620
This commit is contained in:
+92
-40
@@ -3095,22 +3095,26 @@ def check_review_decision_gate(
|
||||
)
|
||||
|
||||
prior = list(lock.get("live_mutations") or [])
|
||||
if prior and not lock.get("correction_authorized"):
|
||||
reasons.append(
|
||||
"live review mutation already recorded in this run; only one live "
|
||||
"review mutation is allowed unless "
|
||||
"gitea_authorize_review_correction was invoked (fail closed)"
|
||||
)
|
||||
elif (
|
||||
action in _TERMINAL_REVIEW_ACTIONS
|
||||
and any(m.get("action") in _TERMINAL_REVIEW_ACTIONS for m in prior)
|
||||
and not lock.get("correction_authorized")
|
||||
ready_head = lock.get("ready_expected_head_sha")
|
||||
if stale_review_decision_lock.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=pr_number, expected_head_sha=ready_head
|
||||
):
|
||||
reasons.append(
|
||||
"terminal review decision already submitted on this PR in this "
|
||||
"run; blocked unless an operator-approved correction was "
|
||||
"authorized (fail closed)"
|
||||
)
|
||||
if prior and not lock.get("correction_authorized"):
|
||||
reasons.append(
|
||||
"live review mutation already recorded for this PR head in this "
|
||||
"run; only one live review mutation is allowed per head unless "
|
||||
"gitea_authorize_review_correction was invoked (fail closed, #620)"
|
||||
)
|
||||
elif (
|
||||
action in _TERMINAL_REVIEW_ACTIONS
|
||||
and any(m.get("action") in _TERMINAL_REVIEW_ACTIONS for m in prior)
|
||||
and not lock.get("correction_authorized")
|
||||
):
|
||||
reasons.append(
|
||||
"terminal review decision already submitted for this PR head in "
|
||||
"this run; blocked unless an operator-approved correction was "
|
||||
"authorized (fail closed, #620)"
|
||||
)
|
||||
|
||||
return reasons
|
||||
|
||||
@@ -3118,12 +3122,18 @@ def check_review_decision_gate(
|
||||
def record_live_review_mutation(pr_number: int, action: str, review_id: int | None = None):
|
||||
lock = _load_review_decision_lock() or {}
|
||||
mutations = list(lock.get("live_mutations") or [])
|
||||
mutations.append({
|
||||
head_sha = stale_review_decision_lock.normalize_head_sha(
|
||||
lock.get("ready_expected_head_sha")
|
||||
)
|
||||
entry = {
|
||||
"pr_number": pr_number,
|
||||
"action": action,
|
||||
"review_id": review_id,
|
||||
"review_state": action,
|
||||
})
|
||||
}
|
||||
if head_sha:
|
||||
entry["head_sha"] = head_sha
|
||||
mutations.append(entry)
|
||||
lock["live_mutations"] = mutations
|
||||
if lock.get("correction_authorized"):
|
||||
lock["correction_authorized"] = False
|
||||
@@ -3131,17 +3141,28 @@ 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).
|
||||
def terminal_review_hard_stop_reasons(
|
||||
pr_number: int,
|
||||
operation: str,
|
||||
expected_head_sha: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Session hard-stop after a terminal live review mutation (#332 / #620).
|
||||
|
||||
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.
|
||||
After a terminal verdict is consumed for a given PR **head**, the only
|
||||
permitted continuation is the merge sequence for that same approved PR
|
||||
(merge does not require a new head). A REQUEST_CHANGES (or an approval of
|
||||
a different PR, or the same head) blocks further review/mark-ready/merge
|
||||
mutations for that boundary.
|
||||
|
||||
*operation* is one of 'merge', 'mark_ready', or 'review'.
|
||||
#620: when *expected_head_sha* differs from the last terminal's reviewed
|
||||
head on the **same open PR**, mark_ready / review / resume may proceed so
|
||||
a fresh formal decision can be recorded for the new head. Historical
|
||||
mutations are preserved.
|
||||
|
||||
An operator-approved correction (#211) re-opens the review path only —
|
||||
never a cross-PR merge.
|
||||
|
||||
*operation* is one of 'merge', 'mark_ready', 'review', or 'resume'.
|
||||
Returns [] when the operation may proceed.
|
||||
"""
|
||||
lock = _load_review_decision_lock()
|
||||
@@ -3160,8 +3181,19 @@ def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[st
|
||||
and last.get("pr_number") == pr_number
|
||||
):
|
||||
return []
|
||||
if operation in ("mark_ready", "review") and lock.get("correction_authorized"):
|
||||
if operation in ("mark_ready", "review", "resume") and lock.get(
|
||||
"correction_authorized"
|
||||
):
|
||||
return []
|
||||
# #620: same PR, different reviewed head → fresh decision boundary.
|
||||
if stale_review_decision_lock.terminal_boundary_allows_fresh_decision(
|
||||
lock,
|
||||
pr_number=pr_number,
|
||||
expected_head_sha=expected_head_sha,
|
||||
operation=operation,
|
||||
):
|
||||
return []
|
||||
locked_head = stale_review_decision_lock.mutation_head_sha(last, lock)
|
||||
if last.get("action") == "approve":
|
||||
guidance = (
|
||||
f"only the merge sequence for approved PR "
|
||||
@@ -3169,15 +3201,22 @@ def terminal_review_hard_stop_reasons(pr_number: int, operation: str) -> list[st
|
||||
)
|
||||
else:
|
||||
guidance = "the session must stop and produce a final report"
|
||||
head_note = (
|
||||
f" at head {locked_head[:12]}…"
|
||||
if locked_head
|
||||
else ""
|
||||
)
|
||||
recovery = (
|
||||
"if that PR is already merged/closed, use "
|
||||
"gitea_cleanup_stale_review_decision_lock (apply=true) after live-state "
|
||||
"proof (#594); never delete session-state files by hand"
|
||||
"proof (#594); if the PR is still open but the head moved, mark/submit "
|
||||
"with the new expected_head_sha (#620); never delete session-state "
|
||||
"files by hand"
|
||||
)
|
||||
return [
|
||||
"terminal review mutation already consumed in this run "
|
||||
f"({last.get('action')} on PR #{last.get('pr_number')}); {guidance} "
|
||||
f"(fail closed, #332); {recovery}"
|
||||
f"({last.get('action')} on PR #{last.get('pr_number')}{head_note}); "
|
||||
f"{guidance} (fail closed, #332); {recovery}"
|
||||
]
|
||||
|
||||
|
||||
@@ -3800,18 +3839,11 @@ def gitea_mark_final_review_decision(
|
||||
"reasons": workflow_blockers + (
|
||||
review_workflow_load.recovery_handoff_without_replay()),
|
||||
}
|
||||
hard_stop = terminal_review_hard_stop_reasons(pr_number, "mark_ready")
|
||||
hard_stop = terminal_review_hard_stop_reasons(
|
||||
pr_number, "mark_ready", expected_head_sha=expected_head_sha
|
||||
)
|
||||
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,
|
||||
"reasons": [
|
||||
"cannot mark final decision after a live review mutation was "
|
||||
"already recorded in this run unless an operator-approved "
|
||||
"correction was authorized"
|
||||
],
|
||||
}
|
||||
if action not in _REVIEW_ACTIONS:
|
||||
return {
|
||||
"marked_ready": False,
|
||||
@@ -3828,6 +3860,17 @@ def gitea_mark_final_review_decision(
|
||||
"decision (fail closed, #399)"
|
||||
],
|
||||
}
|
||||
if stale_review_decision_lock.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
):
|
||||
return {
|
||||
"marked_ready": False,
|
||||
"reasons": [
|
||||
"cannot mark final decision after a live review mutation was "
|
||||
"already recorded for this PR head unless an operator-approved "
|
||||
"correction was authorized (fail closed, #620)"
|
||||
],
|
||||
}
|
||||
elig = gitea_check_pr_eligibility(
|
||||
pr_number=pr_number,
|
||||
action="review",
|
||||
@@ -3880,6 +3923,8 @@ def gitea_mark_final_review_decision(
|
||||
"#332)"
|
||||
],
|
||||
}
|
||||
# Preserve historical terminal head boundaries before advancing ready_* (#620).
|
||||
stale_review_decision_lock.backfill_terminal_heads_from_ready(lock)
|
||||
lock["final_review_decision_ready"] = True
|
||||
lock["ready_pr_number"] = pr_number
|
||||
lock["ready_action"] = action
|
||||
@@ -3897,6 +3942,7 @@ def gitea_mark_final_review_decision(
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"final_review_decision_ready": True,
|
||||
"head_scoped": True,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
@@ -4063,6 +4109,12 @@ def gitea_cleanup_stale_review_decision_lock(
|
||||
"cleanup_allowed": assessment.get("cleanup_allowed"),
|
||||
"last_terminal_pr": assessment.get("last_terminal_pr"),
|
||||
"last_terminal_action": assessment.get("last_terminal_action"),
|
||||
"locked_head_sha": assessment.get("locked_head_sha"),
|
||||
"current_pr_head_sha": assessment.get("current_pr_head_sha"),
|
||||
"stale_by_head": assessment.get("stale_by_head"),
|
||||
"fresh_review_on_current_head_allowed": assessment.get(
|
||||
"fresh_review_on_current_head_allowed"
|
||||
),
|
||||
"pr_state": assessment.get("pr_state"),
|
||||
"pr_merged": assessment.get("pr_merged"),
|
||||
"pr_merged_or_closed": assessment.get("pr_merged_or_closed"),
|
||||
|
||||
Reference in New Issue
Block a user