fix(review): cross-PR decision-lock isolation and recovery diagnosis (Closes #693)
Prevent foreign open-PR terminals on the durable review-decision lock from blocking fresh formal reviews. Scope correction authorization to the named prior review's PR/head, add read-only diagnosis with exact next_action and durable handoff payloads, require thread-visible correction audits, and cover the PR #688 → #692 recovery sequence in regression tests.
This commit is contained in:
+341
-21
@@ -3338,6 +3338,8 @@ def init_review_decision_lock(remote: str | None, task: str | None, force: bool
|
||||
"live_mutations": [],
|
||||
"correction_authorized": False,
|
||||
"correction_reason": None,
|
||||
"correction_pr_number": None,
|
||||
"correction_head_sha": None,
|
||||
})
|
||||
|
||||
|
||||
@@ -3422,21 +3424,30 @@ def check_review_decision_gate(
|
||||
if stale_review_decision_lock.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=pr_number, expected_head_sha=ready_head
|
||||
):
|
||||
if prior and not lock.get("correction_authorized"):
|
||||
scoped = stale_review_decision_lock.correction_applies(
|
||||
lock, pr_number=pr_number, expected_head_sha=ready_head
|
||||
)
|
||||
if prior and not scoped:
|
||||
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)"
|
||||
"gitea_authorize_review_correction was invoked for this same "
|
||||
"PR/head (fail closed, #620/#693)"
|
||||
)
|
||||
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")
|
||||
and any(
|
||||
isinstance(m, dict)
|
||||
and m.get("action") in _TERMINAL_REVIEW_ACTIONS
|
||||
and m.get("pr_number") == pr_number
|
||||
for m in prior
|
||||
)
|
||||
and not scoped
|
||||
):
|
||||
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)"
|
||||
"authorized for this same PR/head (fail closed, #620/#693)"
|
||||
)
|
||||
|
||||
return reasons
|
||||
@@ -3461,6 +3472,8 @@ def record_live_review_mutation(pr_number: int, action: str, review_id: int | No
|
||||
if lock.get("correction_authorized"):
|
||||
lock["correction_authorized"] = False
|
||||
lock["correction_reason"] = None
|
||||
lock["correction_pr_number"] = None
|
||||
lock["correction_head_sha"] = None
|
||||
_save_review_decision_lock(lock)
|
||||
|
||||
|
||||
@@ -3504,11 +3517,13 @@ def terminal_review_hard_stop_reasons(
|
||||
and last.get("pr_number") == pr_number
|
||||
):
|
||||
return []
|
||||
if operation in ("mark_ready", "review", "resume") and lock.get(
|
||||
"correction_authorized"
|
||||
if operation in ("mark_ready", "review", "resume") and (
|
||||
stale_review_decision_lock.correction_applies(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
)
|
||||
):
|
||||
return []
|
||||
# #620: same PR, different reviewed head → fresh decision boundary.
|
||||
# #620 same-PR new head + #693 cross-PR isolation.
|
||||
if stale_review_decision_lock.terminal_boundary_allows_fresh_decision(
|
||||
lock,
|
||||
pr_number=pr_number,
|
||||
@@ -3530,16 +3545,20 @@ def terminal_review_hard_stop_reasons(
|
||||
else ""
|
||||
)
|
||||
recovery = (
|
||||
"if that PR is already merged/closed, use "
|
||||
"gitea_cleanup_stale_review_decision_lock (apply=true) after live-state "
|
||||
"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"
|
||||
"exact_next_action: if that PR is already merged/closed, use "
|
||||
"gitea_cleanup_stale_review_decision_lock(apply=true) after live-state "
|
||||
"proof (#594); if the same PR head moved, mark/submit with the new "
|
||||
"expected_head_sha (#620); if the target is a different PR, "
|
||||
"gitea_diagnose_review_decision_lock then mark_final on the target "
|
||||
"(cross-PR isolation, #693); if same-head verdict was mistaken, "
|
||||
"gitea_authorize_review_correction with matching target_pr_number "
|
||||
"(not a generic unlock); never delete session-state files by hand; "
|
||||
"if still blocked create/update a durable Gitea issue and stop"
|
||||
)
|
||||
return [
|
||||
"terminal review mutation already consumed in this run "
|
||||
f"({last.get('action')} on PR #{last.get('pr_number')}{head_note}); "
|
||||
f"{guidance} (fail closed, #332); {recovery}"
|
||||
f"{guidance} (fail closed, #332/#693); {recovery}"
|
||||
]
|
||||
|
||||
|
||||
@@ -4121,10 +4140,33 @@ def gitea_mark_final_review_decision(
|
||||
"reasons": [
|
||||
"review decision lock missing; resolve review_pr capability first"
|
||||
],
|
||||
"exact_next_action": (
|
||||
"gitea_resolve_task_capability(task='review_pr') then retry "
|
||||
"mark_final"
|
||||
),
|
||||
"durable_issue_handoff": {
|
||||
"required": True,
|
||||
"instruction": (
|
||||
"If the lock cannot be seeded, create/update a durable "
|
||||
"Gitea issue and stop (#693 AC8)"
|
||||
),
|
||||
},
|
||||
}
|
||||
session_reasons = _review_decision_session_reasons(lock)
|
||||
if session_reasons:
|
||||
return {"marked_ready": False, "reasons": session_reasons}
|
||||
classification = stale_review_decision_lock.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=pr_number,
|
||||
target_head_sha=expected_head_sha,
|
||||
session_blockers=session_reasons,
|
||||
)
|
||||
fail = stale_review_decision_lock.build_precise_mark_final_failure(
|
||||
classification
|
||||
)
|
||||
return {
|
||||
"marked_ready": False,
|
||||
**fail,
|
||||
}
|
||||
if remote != lock.get("remote"):
|
||||
return {
|
||||
"marked_ready": False,
|
||||
@@ -4132,6 +4174,7 @@ def gitea_mark_final_review_decision(
|
||||
f"requested remote '{remote}' does not match locked remote "
|
||||
f"'{lock.get('remote')}' (fail closed)"
|
||||
],
|
||||
"exact_next_action": "use the remote bound on the decision lock",
|
||||
}
|
||||
try:
|
||||
_, resolved_org, resolved_repo = _resolve(remote, None, org, repo)
|
||||
@@ -4166,7 +4209,26 @@ def gitea_mark_final_review_decision(
|
||||
pr_number, "mark_ready", expected_head_sha=expected_head_sha
|
||||
)
|
||||
if hard_stop:
|
||||
return {"marked_ready": False, "reasons": hard_stop}
|
||||
classification = stale_review_decision_lock.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=pr_number,
|
||||
target_head_sha=expected_head_sha,
|
||||
active_profile_identity=_decision_lock_binding().get(
|
||||
"profile_identity"
|
||||
),
|
||||
)
|
||||
fail = stale_review_decision_lock.build_precise_mark_final_failure(
|
||||
classification
|
||||
)
|
||||
# Prefer classification next_action when more precise; keep hard_stop
|
||||
# text as primary reasons for operators.
|
||||
return {
|
||||
"marked_ready": False,
|
||||
"reasons": hard_stop + list(fail.get("reasons") or []),
|
||||
"classification": fail.get("classification"),
|
||||
"exact_next_action": fail.get("exact_next_action"),
|
||||
"durable_issue_handoff": fail.get("durable_issue_handoff"),
|
||||
}
|
||||
if action not in _REVIEW_ACTIONS:
|
||||
return {
|
||||
"marked_ready": False,
|
||||
@@ -4182,17 +4244,63 @@ def gitea_mark_final_review_decision(
|
||||
"expected_head_sha required before marking final review "
|
||||
"decision (fail closed, #399)"
|
||||
],
|
||||
"exact_next_action": "retry mark_final with expected_head_sha pin",
|
||||
}
|
||||
# Safe idempotency: already ready for same PR/action/head (#693 AC4).
|
||||
ready_head = stale_review_decision_lock.normalize_head_sha(
|
||||
lock.get("ready_expected_head_sha")
|
||||
)
|
||||
target_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha)
|
||||
if (
|
||||
lock.get("final_review_decision_ready")
|
||||
and lock.get("ready_pr_number") == pr_number
|
||||
and lock.get("ready_action") == action
|
||||
and ready_head
|
||||
and target_head
|
||||
and stale_review_decision_lock.heads_equal(ready_head, target_head)
|
||||
and lock.get("ready_remote") == remote
|
||||
and lock.get("ready_org") == org
|
||||
and lock.get("ready_repo") == repo
|
||||
):
|
||||
return {
|
||||
"marked_ready": True,
|
||||
"idempotent": True,
|
||||
"pr_number": pr_number,
|
||||
"action": action,
|
||||
"expected_head_sha": expected_head_sha,
|
||||
"remote": remote,
|
||||
"org": org,
|
||||
"repo": repo,
|
||||
"final_review_decision_ready": True,
|
||||
"head_scoped": True,
|
||||
"reasons": [
|
||||
"final review decision already marked ready for this PR/action/"
|
||||
"head (idempotent, #693)"
|
||||
],
|
||||
}
|
||||
if stale_review_decision_lock.prior_live_mutations_block_boundary(
|
||||
lock, pr_number=pr_number, expected_head_sha=expected_head_sha
|
||||
):
|
||||
classification = stale_review_decision_lock.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=pr_number,
|
||||
target_head_sha=expected_head_sha,
|
||||
)
|
||||
fail = stale_review_decision_lock.build_precise_mark_final_failure(
|
||||
classification
|
||||
)
|
||||
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)"
|
||||
],
|
||||
"correction was authorized for this same PR/head "
|
||||
"(fail closed, #620/#693)"
|
||||
]
|
||||
+ list(fail.get("reasons") or []),
|
||||
"classification": fail.get("classification"),
|
||||
"exact_next_action": fail.get("exact_next_action"),
|
||||
"durable_issue_handoff": fail.get("durable_issue_handoff"),
|
||||
}
|
||||
elig = gitea_check_pr_eligibility(
|
||||
pr_number=pr_number,
|
||||
@@ -4276,8 +4384,19 @@ def gitea_authorize_review_correction(
|
||||
prior_review_state: str,
|
||||
reason: str,
|
||||
operator_authorized: bool = False,
|
||||
target_pr_number: int | None = None,
|
||||
expected_head_sha: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
post_audit_comment: bool = True,
|
||||
) -> dict:
|
||||
"""Authorize one operator-approved correction after a mistaken live review."""
|
||||
"""Authorize one operator-approved correction after a mistaken live review.
|
||||
|
||||
#693: correction is scoped to the named prior review's **PR and head**.
|
||||
It cannot unlock a different PR (no generic decision-lock bypass).
|
||||
Successful authorization posts a thread-visible audit on the target PR.
|
||||
"""
|
||||
reason = (reason or "").strip()
|
||||
prior_review_state = (prior_review_state or "").strip().lower()
|
||||
lock = _load_review_decision_lock()
|
||||
@@ -4303,7 +4422,20 @@ def gitea_authorize_review_correction(
|
||||
last_mutation = prior[-1]
|
||||
last_review_id = last_mutation.get("review_id")
|
||||
last_review_state = last_mutation.get("action")
|
||||
last_pr = last_mutation.get("pr_number")
|
||||
last_head = stale_review_decision_lock.mutation_head_sha(last_mutation, lock)
|
||||
reasons = []
|
||||
if target_pr_number is None:
|
||||
reasons.append(
|
||||
"target_pr_number is required so correction cannot become a "
|
||||
"generic unlock (fail closed, #693)"
|
||||
)
|
||||
elif last_pr is not None and int(target_pr_number) != int(last_pr):
|
||||
reasons.append(
|
||||
f"target_pr_number #{target_pr_number} does not match last "
|
||||
f"recorded mutation PR #{last_pr}; correction cannot unlock a "
|
||||
f"different PR (fail closed, #693)"
|
||||
)
|
||||
if last_review_id is not None and last_review_id != prior_review_id:
|
||||
reasons.append(
|
||||
f"prior review ID '{prior_review_id}' does not match last "
|
||||
@@ -4314,14 +4446,202 @@ def gitea_authorize_review_correction(
|
||||
f"prior review state '{prior_review_state}' does not match last "
|
||||
f"recorded review state '{last_review_state}' (fail closed)"
|
||||
)
|
||||
want_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha)
|
||||
if want_head and last_head and not stale_review_decision_lock.heads_equal(
|
||||
want_head, last_head
|
||||
):
|
||||
reasons.append(
|
||||
f"expected_head_sha does not match last mutation head "
|
||||
f"{last_head[:12]}… (fail closed, #693)"
|
||||
)
|
||||
if not reason:
|
||||
reasons.append("correction reason is required")
|
||||
try:
|
||||
actor = None
|
||||
h, o, r = _resolve(remote, None, org, repo)
|
||||
try:
|
||||
actor = _authenticated_username(h)
|
||||
except Exception:
|
||||
actor = None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {
|
||||
"authorized": False,
|
||||
"reasons": [f"could not resolve remote for audit: {_redact(str(exc))}"],
|
||||
}
|
||||
profile = get_profile()
|
||||
profile_name = (profile.get("profile_name") or "").strip() or None
|
||||
audit = stale_review_decision_lock.build_correction_audit_record(
|
||||
prior_review_id=prior_review_id,
|
||||
prior_review_state=prior_review_state,
|
||||
target_pr_number=target_pr_number if target_pr_number is not None else last_pr,
|
||||
target_head_sha=want_head or last_head,
|
||||
reason=reason,
|
||||
actor_username=actor,
|
||||
profile_name=profile_name,
|
||||
authorized=False,
|
||||
)
|
||||
if reasons:
|
||||
return {"authorized": False, "reasons": reasons}
|
||||
return {
|
||||
"authorized": False,
|
||||
"reasons": reasons,
|
||||
"audit": audit,
|
||||
"exact_next_action": (
|
||||
"use same-PR correction only, or for a different PR use "
|
||||
"gitea_diagnose_review_decision_lock / cross-PR isolation "
|
||||
"(#693) or moot cleanup (#594)"
|
||||
),
|
||||
}
|
||||
scope_pr = int(target_pr_number) if target_pr_number is not None else int(last_pr)
|
||||
scope_head = want_head or last_head
|
||||
lock["correction_authorized"] = True
|
||||
lock["correction_reason"] = reason
|
||||
lock["correction_pr_number"] = scope_pr
|
||||
lock["correction_head_sha"] = scope_head
|
||||
_save_review_decision_lock(lock)
|
||||
return {"authorized": True, "correction_reason": reason, "reasons": []}
|
||||
audit = stale_review_decision_lock.build_correction_audit_record(
|
||||
prior_review_id=prior_review_id,
|
||||
prior_review_state=prior_review_state,
|
||||
target_pr_number=scope_pr,
|
||||
target_head_sha=scope_head,
|
||||
reason=reason,
|
||||
actor_username=actor,
|
||||
profile_name=profile_name,
|
||||
authorized=True,
|
||||
)
|
||||
report = {
|
||||
"authorized": True,
|
||||
"correction_reason": reason,
|
||||
"correction_pr_number": scope_pr,
|
||||
"correction_head_sha": scope_head,
|
||||
"audit": audit,
|
||||
"audit_comment_id": None,
|
||||
"reasons": [],
|
||||
"scope": "same_pr_head_only",
|
||||
}
|
||||
if post_audit_comment and scope_pr is not None:
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
if comment_block:
|
||||
report["audit_comment_skipped"] = comment_block
|
||||
else:
|
||||
try:
|
||||
auth = _auth(h)
|
||||
body = stale_review_decision_lock.format_correction_audit_comment(
|
||||
audit
|
||||
)
|
||||
with _audited(
|
||||
"comment_pr",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
pr_number=scope_pr,
|
||||
request_metadata={"source": "authorize_review_correction"},
|
||||
):
|
||||
posted = api_request(
|
||||
"POST",
|
||||
f"{repo_api_url(h, o, r)}/issues/{scope_pr}/comments",
|
||||
auth,
|
||||
{"body": body},
|
||||
)
|
||||
report["audit_comment_id"] = (posted or {}).get("id")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report["audit_comment_error"] = _redact(str(exc))
|
||||
return report
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_diagnose_review_decision_lock(
|
||||
target_pr_number: int,
|
||||
expected_head_sha: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Read-only: classify durable review-decision lock state for a target PR (#693).
|
||||
|
||||
Returns a deterministic classification, owner/evidence, exact next_action,
|
||||
and whether mark_final / cleanup / scoped correction are appropriate.
|
||||
Never mutates the lock and never authorizes correction.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
binding = _decision_lock_binding()
|
||||
lock = _load_review_decision_lock()
|
||||
session_blockers = _review_decision_session_reasons(lock) if lock else []
|
||||
last = stale_review_decision_lock.last_terminal_mutation(lock)
|
||||
pr_live = None
|
||||
pr_lookup_error = None
|
||||
last_pr = last.get("pr_number") if last else None
|
||||
if last_pr is not None:
|
||||
try:
|
||||
pr_live = api_request(
|
||||
"GET", f"{repo_api_url(h, o, r)}/pulls/{last_pr}", auth
|
||||
) or {}
|
||||
if not pr_live:
|
||||
pr_lookup_error = "empty PR payload"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
pr_lookup_error = _redact(str(exc))
|
||||
classification = stale_review_decision_lock.classify_review_decision_lock(
|
||||
lock,
|
||||
target_pr_number=target_pr_number,
|
||||
target_head_sha=expected_head_sha,
|
||||
pr_live=pr_live,
|
||||
pr_lookup_error=pr_lookup_error,
|
||||
active_profile_identity=binding.get("profile_identity"),
|
||||
session_blockers=session_blockers,
|
||||
)
|
||||
recovery = stale_review_decision_lock.assess_open_pr_decision_lock_recovery(
|
||||
lock,
|
||||
target_pr_number=target_pr_number,
|
||||
target_head_sha=expected_head_sha,
|
||||
last_terminal_pr_live=pr_live,
|
||||
pr_lookup_error=pr_lookup_error,
|
||||
active_profile_identity=binding.get("profile_identity"),
|
||||
session_blockers=session_blockers,
|
||||
)
|
||||
try:
|
||||
actor = _authenticated_username(h)
|
||||
except Exception:
|
||||
actor = None
|
||||
return {
|
||||
"success": True,
|
||||
"remote": remote,
|
||||
"org": o,
|
||||
"repo": r,
|
||||
"authenticated_username": actor,
|
||||
"active_profile_identity": binding.get("profile_identity"),
|
||||
"classification": classification.get("classification"),
|
||||
"mark_final_allowed": classification.get("mark_final_allowed"),
|
||||
"cleanup_allowed": classification.get("cleanup_allowed"),
|
||||
"correction_eligible": classification.get("correction_eligible"),
|
||||
"recovery_allowed": recovery.get("recovery_allowed"),
|
||||
"recovery_mode": recovery.get("recovery_mode"),
|
||||
"exact_next_action": classification.get("next_action"),
|
||||
"owner_profile_identity": classification.get("owner_profile_identity"),
|
||||
"last_terminal_pr": classification.get("last_terminal_pr"),
|
||||
"last_terminal_action": classification.get("last_terminal_action"),
|
||||
"locked_head_sha": classification.get("locked_head_sha"),
|
||||
"target_pr_number": target_pr_number,
|
||||
"target_head_sha": stale_review_decision_lock.normalize_head_sha(
|
||||
expected_head_sha
|
||||
),
|
||||
"lock_summary": classification.get("lock_summary"),
|
||||
"evidence": classification.get("evidence"),
|
||||
"reasons": list(classification.get("reasons") or []),
|
||||
"manual_file_delete_forbidden": True,
|
||||
"correction_as_generic_unlock_forbidden": True,
|
||||
"durable_issue_handoff": stale_review_decision_lock.build_precise_mark_final_failure(
|
||||
classification
|
||||
).get("durable_issue_handoff"),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
||||
Reference in New Issue
Block a user