feat: verify linked-issue consistency for the selected PR (Closes #314) #360
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user