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:
@@ -1,10 +1,10 @@
|
||||
"""Tests for the session hard-stop after a terminal review mutation (#332).
|
||||
"""Tests for the session hard-stop after a terminal review mutation (#332/#693).
|
||||
|
||||
Extends the single-terminal-decision machinery (#211): after a live
|
||||
REQUEST_CHANGES the run must stop; after an APPROVE only the merge sequence
|
||||
for that same PR may continue; a different PR can never be reviewed or
|
||||
merged in the same run; duplicate REQUEST_CHANGES at an unchanged head is
|
||||
suppressed.
|
||||
REQUEST_CHANGES on a PR head the same-head path must stop; after an APPROVE
|
||||
only the merge sequence for that same PR may continue; #693 allows a
|
||||
*different* PR to be mark_ready/review in the same durable lock (cross-PR
|
||||
isolation); duplicate REQUEST_CHANGES at an unchanged head is suppressed.
|
||||
"""
|
||||
import os
|
||||
import unittest
|
||||
@@ -15,7 +15,10 @@ import mcp_server
|
||||
HEAD_SHA = "a" * 40
|
||||
|
||||
|
||||
def _lock(mutations=None, correction=False):
|
||||
def _lock(mutations=None, correction=False, correction_pr=None):
|
||||
mutations = list(mutations or [])
|
||||
if correction and correction_pr is None and mutations:
|
||||
correction_pr = mutations[-1].get("pr_number")
|
||||
return {
|
||||
"task": "review_pr",
|
||||
"remote": "prgs",
|
||||
@@ -29,9 +32,11 @@ def _lock(mutations=None, correction=False):
|
||||
"ready_remote": None,
|
||||
"ready_org": None,
|
||||
"ready_repo": None,
|
||||
"live_mutations": list(mutations or []),
|
||||
"live_mutations": mutations,
|
||||
"correction_authorized": correction,
|
||||
"correction_reason": None,
|
||||
"correction_reason": "test" if correction else None,
|
||||
"correction_pr_number": correction_pr if correction else None,
|
||||
"correction_head_sha": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -64,25 +69,35 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||
|
||||
def test_request_changes_blocks_everything(self):
|
||||
def test_request_changes_blocks_same_pr_allows_other_pr_review(self):
|
||||
_seed([RC_A])
|
||||
# Same PR: still hard-stopped for mark_ready/review/merge.
|
||||
for op in ("merge", "mark_ready", "review"):
|
||||
for pr in (5, 6):
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(pr, op)
|
||||
self.assertTrue(reasons, f"{op}/{pr}")
|
||||
self.assertIn("request_changes on PR #5", reasons[0])
|
||||
self.assertIn("#332", reasons[0])
|
||||
reasons = mcp_server.terminal_review_hard_stop_reasons(5, op)
|
||||
self.assertTrue(reasons, f"{op}/5")
|
||||
self.assertIn("request_changes on PR #5", reasons[0])
|
||||
self.assertIn("#332", reasons[0])
|
||||
# #693: different PR may mark_ready / review (not merge via hard-stop alone).
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
|
||||
# Merge of an unapproved other PR remains blocked by hard-stop path
|
||||
# (last terminal is not approve of PR 6).
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||
|
||||
def test_approve_allows_same_pr_merge_only(self):
|
||||
def test_approve_allows_same_pr_merge_and_other_pr_review(self):
|
||||
_seed([APPROVED_A])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "merge"), [])
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"))
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"))
|
||||
# #693 cross-PR isolation: other PR formal review path is open.
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), [])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "review"), [])
|
||||
|
||||
def test_correction_reopens_review_path_only(self):
|
||||
_seed([RC_A], correction=True)
|
||||
@@ -90,9 +105,11 @@ class TestTerminalHardStopReasons(unittest.TestCase):
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "mark_ready"), [])
|
||||
self.assertEqual(
|
||||
mcp_server.terminal_review_hard_stop_reasons(5, "review"), [])
|
||||
# correction never opens a cross-PR merge
|
||||
# scoped correction never opens a cross-PR merge
|
||||
self.assertTrue(
|
||||
mcp_server.terminal_review_hard_stop_reasons(6, "merge"))
|
||||
# unscoped/cross-PR correction must not lift PR 6 via correction alone
|
||||
# (PR 6 is allowed by isolation, not by correction scope)
|
||||
|
||||
|
||||
class TestMergeHardStopWiring(unittest.TestCase):
|
||||
@@ -126,14 +143,29 @@ class TestMarkFinalHardStopWiring(unittest.TestCase):
|
||||
mcp_server._save_review_decision_lock(None)
|
||||
mcp_server.review_workflow_load.clear_review_workflow_load()
|
||||
|
||||
def test_mark_ready_blocked_after_terminal_mutation(self):
|
||||
def test_mark_ready_same_pr_blocked_after_terminal_mutation(self):
|
||||
_seed([RC_A])
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=6, action="approve", remote="prgs")
|
||||
pr_number=5, action="approve", remote="prgs",
|
||||
expected_head_sha=HEAD_SHA)
|
||||
self.assertFalse(result["marked_ready"])
|
||||
self.assertTrue(
|
||||
any("#332" in r for r in result["reasons"]), result["reasons"])
|
||||
|
||||
def test_mark_ready_other_pr_allowed_after_foreign_terminal(self):
|
||||
"""#693: foreign open-PR terminal must not block mark_final on PR B."""
|
||||
_seed([RC_A])
|
||||
no_lease = {"block": False, "reasons": [], "mutation_allowed": True}
|
||||
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_SHA, "eligible": True}):
|
||||
result = mcp_server.gitea_mark_final_review_decision(
|
||||
pr_number=6, action="approve", remote="prgs",
|
||||
expected_head_sha=HEAD_SHA)
|
||||
self.assertTrue(result["marked_ready"], result.get("reasons"))
|
||||
|
||||
|
||||
def _feedback(blocking, stale=False, success=True):
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user