diff --git a/review_proofs.py b/review_proofs.py index 78f815d..5fc9a60 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -1052,6 +1052,15 @@ def _prs_from_list_response(list_prs_response: list | dict | None) -> list | Non return None +# ── Linked-issue consistency (Issue #314) ──────────────────────────────────── +# +# Stale linked-issue text from a previous PR must not leak into a final +# report: the "Linked issue status" field must match the issue(s) the +# selected PR actually links, verified live this session, and no other +# "Issue #N" mention may appear unless it is a verified linked issue. + +_ISSUE_MENTION_RE = re.compile(r"\bissue\s+#(\d+)", re.IGNORECASE) + # ── Workspace-vs-worktree mutation consistency (Issue #313) ────────────────── # # A worktree reset/checkout/clean is a workspace mutation even when the @@ -1092,6 +1101,68 @@ def _report_labeled_fields(report_text): return fields +def _issue_numbers_mentioned(text): + """Return the set of ints referenced as 'Issue #N' in *text*.""" + return {int(n) for n in _ISSUE_MENTION_RE.findall(text or "")} + + +def assess_linked_issue_consistency(report_text, *, selected_pr=None, + linked_issues=None): + """#314: linked-issue status must match the selected PR, live-verified. + + *linked_issues* is the list of issue numbers the selected PR was + live-verified to link this session, or None when no live verification + happened. Without live proof the report may only say the status was + not verified; with proof, the ``Linked issue status`` field must name + every linked issue and no stale issue number may appear anywhere in + the report. + + Returns {'complete', 'downgraded', 'reasons'}; fails closed. + """ + fields = _report_labeled_fields(report_text) + status_value = fields.get("linked issue status") + + reasons = [] + + if status_value is None: + reasons.append( + "final report missing 'Linked issue status' field for the " + "selected PR" + ) + elif linked_issues is None: + if not status_value.strip().lower().startswith("not verified"): + reasons.append( + "linked issue status claimed without live proof; report " + "'Linked issue status: not verified in this session' or " + "verify the linked issue live" + ) + else: + allowed = set(linked_issues) + status_mentions = _issue_numbers_mentioned(status_value) + for missing in sorted(allowed - status_mentions): + reasons.append( + f"linked issue #{missing} of selected PR " + f"#{selected_pr} not reported in 'Linked issue status'" + ) + for stale in sorted(status_mentions - allowed): + reasons.append( + f"'Linked issue status' names issue #{stale}, which is " + f"not a live-verified linked issue of selected PR " + f"#{selected_pr}" + ) + for stale in sorted(_issue_numbers_mentioned(report_text) - allowed): + reasons.append( + f"stale issue #{stale} mentioned in final report but not " + f"live-verified as linked to selected PR #{selected_pr}" + ) + + return { + "complete": not reasons, + "downgraded": bool(reasons), + "reasons": reasons, + } + + def _field_claims_none(fields, label): """True when *label* is present and its value is empty or 'none...'.""" value = fields.get(label) diff --git a/tests/test_linked_issue_consistency.py b/tests/test_linked_issue_consistency.py new file mode 100644 index 0000000..cf325bd --- /dev/null +++ b/tests/test_linked_issue_consistency.py @@ -0,0 +1,118 @@ +"""Tests for linked-issue consistency verifier (#314). + +Reviewer reports must verify the linked issue live for the selected PR; +stale issue numbers from a previous PR must not leak into the final +report, and linked-issue status must never be claimed without live proof. +""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from review_proofs import assess_linked_issue_consistency # noqa: E402 + + +class TestCorrectLinkedIssue(unittest.TestCase): + def test_matching_linked_issue_passes(self): + report = ( + "Selected PR: 280\n" + "Linked issue status: Issue #275 open, closes on merge\n" + ) + result = assess_linked_issue_consistency( + report, selected_pr=280, linked_issues=[275] + ) + self.assertTrue(result["complete"]) + self.assertFalse(result["downgraded"]) + self.assertEqual(result["reasons"], []) + + def test_multiple_linked_issues_all_reported_passes(self): + report = ( + "Linked issue status: Issue #275 and Issue #276 both open\n" + ) + result = assess_linked_issue_consistency( + report, selected_pr=280, linked_issues=[275, 276] + ) + self.assertTrue(result["complete"]) + + def test_multiple_linked_issues_one_missing_fails(self): + report = "Linked issue status: Issue #275 open\n" + result = assess_linked_issue_consistency( + report, selected_pr=280, linked_issues=[275, 276] + ) + self.assertFalse(result["complete"]) + self.assertTrue( + any("276" in r for r in result["reasons"]) + ) + + +class TestStaleIssueLeak(unittest.TestCase): + def test_stale_issue_in_status_field_fails(self): + # #314 observed behavior: PR #280 links Issue #275 but the report + # carries Issue #260 from the previous PR. + report = "Linked issue status: Issue #260 open\n" + result = assess_linked_issue_consistency( + report, selected_pr=280, linked_issues=[275] + ) + self.assertFalse(result["complete"]) + self.assertTrue(any("260" in r for r in result["reasons"])) + + def test_stale_issue_in_diagnostics_fails(self): + report = ( + "Linked issue status: Issue #275 open\n" + "Read-only diagnostics: viewed Issue #260 status\n" + ) + result = assess_linked_issue_consistency( + report, selected_pr=280, linked_issues=[275] + ) + self.assertFalse(result["complete"]) + self.assertTrue( + any("stale" in r.lower() and "260" in r for r in result["reasons"]) + ) + + def test_selected_pr_number_mention_is_not_stale(self): + # "PR #280" mentions must not be confused with issue mentions. + report = ( + "Selected PR: 280\n" + "Validation: ran tests at PR #280 head\n" + "Linked issue status: Issue #275 open\n" + ) + result = assess_linked_issue_consistency( + report, selected_pr=280, linked_issues=[275] + ) + self.assertTrue(result["complete"]) + + +class TestMissingProof(unittest.TestCase): + def test_status_claim_without_live_proof_fails(self): + report = "Linked issue status: Issue #275 open\n" + result = assess_linked_issue_consistency( + report, selected_pr=280, linked_issues=None + ) + self.assertFalse(result["complete"]) + self.assertTrue( + any("live proof" in r.lower() for r in result["reasons"]) + ) + + def test_unverified_wording_without_proof_passes(self): + report = ( + "Linked issue status: not verified in this session\n" + ) + result = assess_linked_issue_consistency( + report, selected_pr=280, linked_issues=None + ) + self.assertTrue(result["complete"]) + + def test_missing_field_fails(self): + report = "Selected PR: 280\n" + result = assess_linked_issue_consistency( + report, selected_pr=280, linked_issues=[275] + ) + self.assertFalse(result["complete"]) + self.assertTrue( + any("linked issue status" in r.lower() for r in result["reasons"]) + ) + + +if __name__ == "__main__": + unittest.main()