diff --git a/scripts/epic_body_staleness.py b/scripts/epic_body_staleness.py index 62b45e9894..22a12a1d38 100644 --- a/scripts/epic_body_staleness.py +++ b/scripts/epic_body_staleness.py @@ -134,13 +134,21 @@ def cited_issues(self) -> set[int]: @dataclass(frozen=True) class EpicStaleness: - """Triage signals for one EPIC.""" + """Triage signals for one EPIC. + + ``citation_breadth`` is the largest number of examined EPICs that any single + ``unrecorded_merged`` PR cites. A PR quoting many issue numbers at once (a + belt dump, a dispatch list) is attributed to every EPIC it names, so a + breadth well above 1 marks a citation-list rather than a delivery: such a + finding is a candidate for human review, never for a body rewrite. + """ number: int title: str unrecorded_merged: tuple[int, ...] stance_contradicted: bool stance_pattern: str | None + citation_breadth: int = 0 def is_epic(row: dict) -> bool: @@ -215,6 +223,11 @@ def analyze_epics( for issue_number in pr.cited_issues() & cited_by_epic.keys(): cited_by_epic[issue_number].append(pr) + breadth: dict[int, int] = {} + for citing_epic in cited_by_epic.values(): + for pr in citing_epic: + breadth[pr.number] = breadth.get(pr.number, 0) + 1 + findings: list[EpicStaleness] = [] for epic in epics: citing = cited_by_epic[epic.number] @@ -236,6 +249,9 @@ def analyze_epics( unrecorded_merged=unrecorded, stance_contradicted=stance is not None, stance_pattern=stance, + citation_breadth=max( + (breadth[number] for number in unrecorded), default=0 + ), ) ) diff --git a/scripts/tests/test_epic_body_staleness.py b/scripts/tests/test_epic_body_staleness.py index 4052b7be82..925b7215f9 100644 --- a/scripts/tests/test_epic_body_staleness.py +++ b/scripts/tests/test_epic_body_staleness.py @@ -63,6 +63,65 @@ def test_up_to_date_epic_has_no_signal(): ) == [] +def test_citation_list_pr_reports_its_epic_breadth(): + """A PR quoting several EPICs is a citation list, not a delivery.""" + epics = [ + Epic(10355, "[EPIC] Fallacy detection", "## Objectif\nDetecter."), + Epic(7265, "[EPIC] Heritage", "## Objectif\nHeriter."), + Epic(11044, "[EPIC] Debt", "## Objectif\nPayer."), + ] + shared = _pr(19147, "Belt dump: #10355 #7265 #11044", title="fix(picker)") + + findings = analyze_epics(epics, [shared]) + + assert sorted(f.number for f in findings) == [7265, 10355, 11044] + assert all(f.citation_breadth == 3 for f in findings) + + +def test_single_epic_citation_keeps_breadth_one(): + """The ordinary 1:1 shape stays distinguishable from a citation list.""" + epics = [ + Epic(10355, "[EPIC] Fallacy detection", "## Objectif\nDetecter."), + Epic(7265, "[EPIC] Heritage", "## Objectif\nHeriter."), + ] + + findings = analyze_epics(epics, [_pr(19147, "See #10355")]) + + assert len(findings) == 1 + assert findings[0].number == 10355 + assert findings[0].citation_breadth == 1 + + +def test_breadth_zero_when_the_citing_pr_is_already_recorded(): + """A stance-only finding carries no PR breadth at all.""" + epic = Epic( + 7, + "[EPIC] Veille", + "Pas pour maintenant.\n\n## Suivi\n- #80 — deja inscrit", + ) + + findings = analyze_epics([epic], [_pr(80, "See #7")]) + + assert len(findings) == 1 + assert findings[0].unrecorded_merged == () + assert findings[0].citation_breadth == 0 + + +def test_payload_exposes_citation_breadth(): + """The additive field survives serialization for downstream triage.""" + epics = [ + Epic(10355, "[EPIC] Fallacy detection", "## Objectif\nDetecter."), + Epic(7265, "[EPIC] Heritage", "## Objectif\nHeriter."), + ] + + payload = build_payload(epics, [_pr(19147, "Belt dump: #10355 #7265")]) + + assert {f["number"]: f["citation_breadth"] for f in payload["findings"]} == { + 10355: 2, + 7265: 2, + } + + def test_quoted_dormant_stance_is_not_live(): epic = Epic( 1210,