"""Review-decision-lock recovery / cross-PR isolation (#693). Reproduces the PR #688 → PR #692 formal-review sequence: * durable lock holds REQUEST_CHANGES on open PR #688 @ head A (review_id 425) * reviewer leases PR #692 @ head B (6d86db…) * mark_final for #692 must succeed without correction unlock * cleanup of open #688 terminal remains forbidden (#594) * authorize_review_correction for #688 cannot unlock #692 * eventual #692 APPROVE is unique and head-bound (review_id 426) * mark_final is idempotent for same PR/action/head * diagnosis returns one exact next_action + durable handoff payload """ from __future__ import annotations import os import unittest from unittest.mock import patch import mcp_server import review_workflow_load import stale_review_decision_lock as srdl # PR #688 / #692 incident fixtures (sanitized reconstruction from issue #693) HEAD_688 = "c7a444eb4b41cf916fdbd20a4999ffd78af496d0" HEAD_692 = "6d86db793bbdfaed16bd54d93171f1dd66f234ca" PR_A = 688 PR_B = 692 REVIEW_ID_A = 425 REVIEW_ID_B = 426 RC_688 = { "pr_number": PR_A, "action": "request_changes", "review_id": REVIEW_ID_A, "review_state": "request_changes", "head_sha": HEAD_688, } APPROVE_692 = { "pr_number": PR_B, "action": "approve", "review_id": REVIEW_ID_B, "review_state": "approve", "head_sha": HEAD_692, } def _lock(mutations=None, **kwargs): from datetime import datetime, timezone now = datetime.now(timezone.utc).isoformat() base = { "task": "review_pr", "remote": "prgs", "org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools", "session_pid": os.getpid(), "session_profile": "prgs-reviewer", "session_profile_lock": "prgs-reviewer", "profile_identity": "prgs-reviewer", "final_review_decision_ready": False, "ready_pr_number": None, "ready_action": None, "ready_expected_head_sha": None, "ready_remote": None, "ready_org": None, "ready_repo": None, "live_mutations": list(mutations or []), "correction_authorized": False, "correction_reason": None, "correction_pr_number": None, "correction_head_sha": None, "updated_at": now, "recorded_at": now, "writer_pid": os.getpid(), } base.update(kwargs) # Ensure TTL fields stay fresh unless the caller is testing expiry. if "recorded_at" not in kwargs: base["recorded_at"] = now if "updated_at" not in kwargs: base["updated_at"] = now return base REVIEWER_ENV = { "GITEA_PROFILE_NAME": "prgs-reviewer", "GITEA_ALLOWED_OPERATIONS": ( "gitea.read,gitea.pr.review,gitea.pr.approve," "gitea.pr.request_changes,gitea.pr.comment" ), "GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer", } REVIEWER_PROFILE = { "profile_name": "prgs-reviewer", "allowed_operations": [ "gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.request_changes", "gitea.pr.comment", ], "forbidden_operations": ["gitea.pr.merge"], } def _seed(mutations=None, **kwargs): review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT) mcp_server._save_review_decision_lock(_lock(mutations, **kwargs)) mcp_server.gitea_load_review_workflow() def _no_lease(): return {"block": False, "reasons": [], "mutation_allowed": True} def _open_pr(number, head): return { "number": number, "state": "open", "merged": False, "merged_at": None, "head": {"sha": head}, } class TestCrossPrIsolationPure(unittest.TestCase): def test_foreign_terminal_does_not_block_mark_boundary(self): lock = _lock([RC_688]) self.assertFalse( srdl.prior_live_mutations_block_boundary( lock, pr_number=PR_B, expected_head_sha=HEAD_692 ) ) self.assertTrue( srdl.prior_live_mutations_block_boundary( lock, pr_number=PR_A, expected_head_sha=HEAD_688 ) ) def test_terminal_boundary_allows_fresh_decision_on_other_pr(self): lock = _lock([RC_688]) self.assertTrue( srdl.terminal_boundary_allows_fresh_decision( lock, pr_number=PR_B, expected_head_sha=HEAD_692, operation="mark_ready", ) ) self.assertFalse( srdl.terminal_boundary_allows_fresh_decision( lock, pr_number=PR_A, expected_head_sha=HEAD_688, operation="mark_ready", ) ) def test_unscoped_correction_does_not_apply(self): lock = _lock([RC_688], correction_authorized=True, correction_reason="x") # Missing correction_pr_number → fail closed (#693) self.assertFalse( srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688) ) def test_scoped_correction_only_for_named_pr(self): lock = _lock( [RC_688], correction_authorized=True, correction_reason="mistake", correction_pr_number=PR_A, correction_head_sha=HEAD_688, ) self.assertTrue( srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688) ) self.assertFalse( srdl.correction_applies(lock, pr_number=PR_B, expected_head_sha=HEAD_692) ) class TestClassification(unittest.TestCase): def test_foreign_pr_terminal_classification(self): lock = _lock([RC_688]) c = srdl.classify_review_decision_lock( lock, target_pr_number=PR_B, target_head_sha=HEAD_692, pr_live=_open_pr(PR_A, HEAD_688), active_profile_identity="prgs-reviewer", ) self.assertEqual(c["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL) self.assertTrue(c["mark_final_allowed"]) self.assertFalse(c["correction_eligible"]) self.assertIn("do not call gitea_authorize_review_correction", c["next_action"]) def test_same_head_terminal_classification(self): lock = _lock([RC_688]) c = srdl.classify_review_decision_lock( lock, target_pr_number=PR_A, target_head_sha=HEAD_688, pr_live=_open_pr(PR_A, HEAD_688), ) self.assertEqual(c["classification"], srdl.CLASS_TERMINAL_ACTIVE_SAME_HEAD) self.assertFalse(c["mark_final_allowed"]) self.assertTrue(c["correction_eligible"]) def test_precise_failure_includes_durable_handoff(self): lock = _lock([RC_688]) c = srdl.classify_review_decision_lock( lock, target_pr_number=PR_A, target_head_sha=HEAD_688 ) fail = srdl.build_precise_mark_final_failure(c) self.assertIn("exact_next_action", fail) self.assertIn("durable_issue_handoff", fail) self.assertTrue(fail["durable_issue_handoff"]["required"]) class TestPr692Sequence(unittest.TestCase): def setUp(self): self._env = patch.dict(os.environ, REVIEWER_ENV, clear=False) self._env.start() self._prof = patch.object( mcp_server, "get_profile", return_value=REVIEWER_PROFILE ) self._prof.start() def tearDown(self): self._prof.stop() self._env.stop() mcp_server._save_review_decision_lock(None) review_workflow_load.clear_review_workflow_load() def test_mark_final_692_succeeds_with_open_688_terminal(self): _seed([RC_688], writer_pid=25883, session_pid=60615) with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch( "mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease() ), patch.object( mcp_server, "gitea_check_pr_eligibility", return_value={"head_sha": HEAD_692, "eligible": True}, ): result = mcp_server.gitea_mark_final_review_decision( pr_number=PR_B, action="approve", expected_head_sha=HEAD_692, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", ) self.assertTrue(result["marked_ready"], result.get("reasons")) lock = mcp_server._load_review_decision_lock() self.assertEqual(lock["ready_pr_number"], PR_B) self.assertEqual( srdl.normalize_head_sha(lock["ready_expected_head_sha"]), HEAD_692 ) # Foreign terminal history preserved (non-destructive isolation). self.assertEqual(lock["live_mutations"][0]["pr_number"], PR_A) def test_mark_final_idempotent_same_binding(self): _seed( [RC_688], final_review_decision_ready=True, ready_pr_number=PR_B, ready_action="approve", ready_expected_head_sha=HEAD_692, ready_remote="prgs", ready_org="Scaled-Tech-Consulting", ready_repo="Gitea-Tools", ) with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch( "mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease() ), patch.object( mcp_server, "gitea_check_pr_eligibility", return_value={"head_sha": HEAD_692, "eligible": True}, ): result = mcp_server.gitea_mark_final_review_decision( pr_number=PR_B, action="approve", expected_head_sha=HEAD_692, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", ) self.assertTrue(result["marked_ready"]) self.assertTrue(result.get("idempotent")) def test_cleanup_still_forbidden_while_688_open(self): _seed([RC_688]) assessment = srdl.assess_stale_review_decision_lock( mcp_server._load_review_decision_lock(), pr_live=_open_pr(PR_A, HEAD_688), active_profile_identity="prgs-reviewer", ) self.assertFalse(assessment["cleanup_allowed"]) self.assertFalse(assessment["is_moot"]) def test_correction_cannot_unlock_different_pr(self): _seed([RC_688]) r = mcp_server.gitea_authorize_review_correction( prior_review_id=REVIEW_ID_A, prior_review_state="request_changes", reason="try unlock 692", operator_authorized=True, target_pr_number=PR_B, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", post_audit_comment=False, ) self.assertFalse(r["authorized"]) self.assertTrue(any("different PR" in x for x in r["reasons"])) def test_scoped_correction_for_same_pr_posts_audit(self): _seed([RC_688]) with patch( "mcp_server._authenticated_username", return_value="sysadmin" ), patch( "mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0" ), patch( "mcp_server._auth", return_value="Basic dGVzdDp0ZXN0" ), patch("mcp_server.api_request", return_value={"id": 99901}): r = mcp_server.gitea_authorize_review_correction( prior_review_id=REVIEW_ID_A, prior_review_state="request_changes", reason="mistaken RC wording", operator_authorized=True, target_pr_number=PR_A, expected_head_sha=HEAD_688, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", post_audit_comment=True, ) self.assertTrue(r["authorized"], r.get("reasons")) self.assertEqual(r.get("correction_pr_number"), PR_A) self.assertEqual( r.get("audit_comment_id"), 99901, r.get("audit_comment_skipped") or r.get("audit_comment_error") or r, ) def test_diagnose_foreign_terminal_next_action(self): _seed([RC_688]) with patch( "mcp_server._authenticated_username", return_value="sysadmin" ), patch( "mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0" ), patch( "mcp_server._auth", return_value="Basic dGVzdDp0ZXN0" ), patch("mcp_server.api_request", return_value=_open_pr(PR_A, HEAD_688)): d = mcp_server.gitea_diagnose_review_decision_lock( target_pr_number=PR_B, expected_head_sha=HEAD_692, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", ) self.assertTrue(d["success"], d.get("reasons")) self.assertEqual(d["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL) self.assertTrue(d["mark_final_allowed"]) self.assertIn("mark_final", d["exact_next_action"]) self.assertTrue(d["correction_as_generic_unlock_forbidden"]) def test_approval_ledger_unique_and_bound_after_sequence(self): """After mark_final + record mutation, ledger binds unique approve to 692 head.""" _seed([RC_688]) with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch( "mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease() ), patch.object( mcp_server, "gitea_check_pr_eligibility", return_value={"head_sha": HEAD_692, "eligible": True}, ): marked = mcp_server.gitea_mark_final_review_decision( pr_number=PR_B, action="approve", expected_head_sha=HEAD_692, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", ) self.assertTrue(marked["marked_ready"]) mcp_server.record_live_review_mutation(PR_B, "approve", REVIEW_ID_B) lock = mcp_server._load_review_decision_lock() terminals = [ m for m in lock["live_mutations"] if m.get("action") in srdl.TERMINAL_REVIEW_ACTIONS and m.get("pr_number") == PR_B ] self.assertEqual(len(terminals), 1) self.assertEqual(terminals[0]["review_id"], REVIEW_ID_B) self.assertEqual( srdl.normalize_head_sha(terminals[0].get("head_sha")), HEAD_692 ) # Same-head duplicate still blocked. self.assertTrue( srdl.prior_live_mutations_block_boundary( lock, pr_number=PR_B, expected_head_sha=HEAD_692 ) ) class TestSessionRestartWriterPid(unittest.TestCase): def tearDown(self): mcp_server._save_review_decision_lock(None) review_workflow_load.clear_review_workflow_load() def test_writer_pid_change_does_not_inherit_foreign_block(self): # session_pid 60615 origin, writer later 25883 — still foreign isolation. _seed([RC_688], session_pid=60615, writer_pid=25883) self.assertEqual( mcp_server.terminal_review_hard_stop_reasons( PR_B, "mark_ready", expected_head_sha=HEAD_692 ), [], ) if __name__ == "__main__": unittest.main()