feat: hard-stop review mutations on already-landed PR heads (Closes #292)

A reviewer workflow approved and attempted to merge PR #278 although its
head commit was already an ancestor of master, ending in a Gitea 405 and
manual reconciliation. Add the missing pre-mutation gate:

- assess_already_landed_review_gate classifies the pinned candidate
  head against the fetched target branch: NORMAL_REVIEW_CANDIDATE,
  ALREADY_LANDED_RECONCILE_REQUIRED, or GATE_NOT_PROVEN (fail closed).
  Already-landed PRs block approval and the merge API, allow
  request-changes only for a real blocker, and require a reconciliation
  handoff (PR number/title, candidate head SHA, target branch + SHA,
  ancestor proof, linked issue status, recommended action, capability
  proof for close/comment mutations). Unchecked ancestry, invalid or
  missing SHAs, and a head changed since pinning all fail closed.
  Mergeability stays a separate gate.

- assess_already_landed_report_state rejects APPROVED / MERGED /
  READY_TO_MERGE wording and missing ALREADY_LANDED_RECONCILE_REQUIRED
  state when the session's gate fired, complementing the text-marker
  rules from #327 which need the report to admit the landed state.

Tests cover already-landed, normal, non-mergeable, changed-head,
unchecked-ancestry, and invalid-SHA gate paths plus all report-state
verdicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-07 05:36:10 -04:00
co-authored by Claude Opus 4.8
parent 634d8a17aa
commit 876f1ee3a3
2 changed files with 302 additions and 0 deletions
+136
View File
@@ -23,6 +23,8 @@ sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.par
from review_proofs import ( # noqa: E402
ISSUE_SELECTION_CONTINUATION_EXPLICIT,
ISSUE_SELECTION_REPRESENTED_BY_OPEN_PR,
assess_already_landed_report_state,
assess_already_landed_review_gate,
assess_author_pr_report,
assess_reviewer_baseline_validation_proof,
assess_capability_evidence,
@@ -2281,5 +2283,139 @@ class TestReviewerBaselineValidationProof(unittest.TestCase):
self.assertFalse(report["baseline_validation_proven"])
class TestAlreadyLandedReviewGate(unittest.TestCase):
"""Issue #292: PR head already on target blocks approval and merge."""
def _gate(self, **overrides):
kwargs = {
"pr_number": 278,
"candidate_head_sha": PINNED,
"target_branch": "master",
"target_branch_sha": OTHER,
"head_is_ancestor_of_target": True,
"live_head_sha": PINNED,
}
kwargs.update(overrides)
return assess_already_landed_review_gate(**kwargs)
def test_already_landed_blocks_approval_and_merge(self):
result = self._gate()
self.assertEqual(result["state"], "ALREADY_LANDED_RECONCILE_REQUIRED")
self.assertFalse(result["approve_allowed"])
self.assertFalse(result["merge_api_allowed"])
self.assertFalse(result["request_changes_allowed"])
self.assertTrue(result["reconciliation_handoff_required"])
def test_already_landed_with_real_blocker_allows_request_changes(self):
result = self._gate(real_blocker=True)
self.assertEqual(result["state"], "ALREADY_LANDED_RECONCILE_REQUIRED")
self.assertFalse(result["approve_allowed"])
self.assertFalse(result["merge_api_allowed"])
self.assertTrue(result["request_changes_allowed"])
def test_normal_candidate_passes_gate(self):
result = self._gate(head_is_ancestor_of_target=False)
self.assertEqual(result["state"], "NORMAL_REVIEW_CANDIDATE")
self.assertTrue(result["approve_allowed"])
self.assertTrue(result["merge_api_allowed"])
self.assertFalse(result["reconciliation_handoff_required"])
def test_non_mergeable_normal_pr_still_passes_this_gate(self):
# Mergeability/conflicts are separate gates; ancestry gate only
# decides already-landed vs normal candidate.
result = self._gate(
head_is_ancestor_of_target=False, mergeable=False)
self.assertEqual(result["state"], "NORMAL_REVIEW_CANDIDATE")
self.assertTrue(result["request_changes_allowed"])
def test_unchecked_ancestry_blocks_review_mutations(self):
result = self._gate(head_is_ancestor_of_target=None)
self.assertEqual(result["state"], "GATE_NOT_PROVEN")
self.assertFalse(result["approve_allowed"])
self.assertFalse(result["merge_api_allowed"])
def test_changed_head_since_pin_blocks_until_recheck(self):
result = self._gate(
head_is_ancestor_of_target=False, live_head_sha=OTHER)
self.assertEqual(result["state"], "GATE_NOT_PROVEN")
self.assertFalse(result["approve_allowed"])
self.assertTrue(any(
"head" in reason.lower() for reason in result["reasons"]
))
def test_invalid_shas_block_gate(self):
result = self._gate(candidate_head_sha="deadbeef")
self.assertEqual(result["state"], "GATE_NOT_PROVEN")
self.assertFalse(result["approve_allowed"])
result = self._gate(target_branch_sha=None)
self.assertEqual(result["state"], "GATE_NOT_PROVEN")
def test_already_landed_handoff_lists_required_fields(self):
result = self._gate()
for field in (
"PR number/title",
"candidate head SHA",
"target branch",
"target branch SHA",
"ancestor proof",
"linked issue status",
"recommended reconciliation action",
"capability proof for close/comment mutations",
):
self.assertIn(field, result["required_handoff_fields"])
class TestAlreadyLandedReportState(unittest.TestCase):
"""Issue #292: reports cannot say APPROVED after the gate fired."""
GOOD_REPORT = (
"Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED\n"
"Candidate head SHA: " + PINNED + "\n"
"Review decision: none\n"
"Merge result: none\n"
)
def test_gate_fired_with_reconcile_state_passes(self):
result = assess_already_landed_report_state(
self.GOOD_REPORT, gate_fired=True)
self.assertTrue(result["complete"])
self.assertFalse(result["block"])
def test_gate_fired_with_approved_state_blocks(self):
report = self.GOOD_REPORT.replace(
"Review decision: none", "Review decision: APPROVED")
result = assess_already_landed_report_state(report, gate_fired=True)
self.assertTrue(result["block"])
def test_gate_fired_with_merged_state_blocks(self):
report = self.GOOD_REPORT.replace(
"Merge result: none", "Merge result: MERGED")
result = assess_already_landed_report_state(report, gate_fired=True)
self.assertTrue(result["block"])
def test_gate_fired_with_ready_to_merge_blocks(self):
result = assess_already_landed_report_state(
self.GOOD_REPORT + "Current status: READY_TO_MERGE\n",
gate_fired=True)
self.assertTrue(result["block"])
def test_gate_fired_without_reconcile_state_blocks(self):
result = assess_already_landed_report_state(
"Current status: review complete.\n", gate_fired=True)
self.assertTrue(result["block"])
self.assertTrue(any(
"already_landed_reconcile_required" in reason.lower()
for reason in result["reasons"]
))
def test_gate_not_fired_reports_pass_untouched(self):
result = assess_already_landed_report_state(
"Review decision: APPROVED\nMerge result: MERGED\n",
gate_fired=False)
self.assertTrue(result["complete"])
self.assertFalse(result["block"])
if __name__ == "__main__":
unittest.main()