From b04430e4ddd7d414d249a7a168618e338c7049d2 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sun, 27 Sep 2026 16:44:56 +0200 Subject: [PATCH] fix(ci,#18039): detect-dup self-test verse la paire de controle dans le lot scanne Le self-test quotidien est rouge depuis le 22/09 : --limit 600 rend les 600 issues les plus recentes et la paire de controle #13050/#13051 (26/08) est sortie de cette fenetre comptee (~5000 numeros en arriere). Le detecteur etait sain, son controle ne voyait plus sa cible. Fix (voie gh issue view de #18039) : sous --self-test, les issues de controle absentes du lot sont recuperees via gh issue view et versees dans le lot scanne. Le detecteur trouve toujours la paire par titre+fenetre -- rien n'est code en dur. Une issue de controle inrecuperable (supprimee) reste hors du lot -> controle missing -> exit 2 (fail-closed). Controle negatif verifie : une seule issue de la paire verssee = pas de burst = exit 2. Verrou de regression : le test unitaire simule le depot grandi (paire hors fenetre) et exige exit 1. Tests : 18 passed + 1 live passed (DETECT_DUP_NETWORK=1) ; CLI live : exit 1, scanned=602, controls_found=[[13050,13051]] ; window 0 : exit 2. See #18039 Co-Authored-By: Claude Sonnet 5 --- .github/workflows/detect-dup-selftest.yml | 7 +++ scripts/detect_duplicate_issues.py | 53 ++++++++++++++++ scripts/tests/test_detect_duplicate_issues.py | 60 ++++++++++++++++++- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/.github/workflows/detect-dup-selftest.yml b/.github/workflows/detect-dup-selftest.yml index 9a2f09d0f4..cacb5e73f0 100644 --- a/.github/workflows/detect-dup-selftest.yml +++ b/.github/workflows/detect-dup-selftest.yml @@ -21,6 +21,13 @@ name: Detect-Duplicate Self-Test # 3. Failure visibility: under the blind window the control MUST go # missing and the CLI MUST exit 2 -- if blindness stops producing # exit 2, the job goes red. +# +# #18039: the control pair #13050/#13051 is ~5000 numbers behind the head, +# so `--limit 600` alone can no longer reach it -- the workflow steps below +# keep their flags, and the CLI pours the control issues into the scanned +# lot itself (`gh issue view`, see _gh_issue_view). The detector still +# finds the burst by title+window; nothing is hardcoded. The unfetchable- +# control case (issue deleted) stays exit 2 by design. on: schedule: diff --git a/scripts/detect_duplicate_issues.py b/scripts/detect_duplicate_issues.py index f7c03b5c36..b027ceaaa9 100644 --- a/scripts/detect_duplicate_issues.py +++ b/scripts/detect_duplicate_issues.py @@ -24,6 +24,15 @@ `--self-test` and the unit tests in `scripts/tests/test_detect_duplicate_issues.py`. +Under `--self-test` the control issues are **poured into the scanned lot** +via `gh issue view` (#18039): a counted window (`--limit N`) slides with the +repository's head and the pair (~5000 numbers behind at #18039) had left it, +turning the daily self-test permanently red. The pour fetches the real +issues and adds them to the lot; the **detector** still has to find the pair +by title equality within the window -- nothing is hardcoded. If an issue of +a control pair cannot be fetched (deleted, transferred), it is absent from +the lot by definition: the control goes missing and the self-test exits 2. + What it does NOT do ------------------- - It does NOT close duplicates. Closing is a human/coord decision because @@ -161,6 +170,29 @@ def _gh_issue_list(limit: int, state: str = "all") -> list[dict]: return json.loads(proc.stdout) +def _gh_issue_view(number: int) -> dict: + """Fetch ONE issue by number via `gh issue view`. + + Used only by the self-test pour (#18039): the positive-control pair + lives far behind the repository head, so a `--limit N` window can never + reach it. The row returned is the real GitHub record -- the detector + must still find the burst by itself. + """ + proc = subprocess.run( + [ + "gh", "issue", "view", str(number), + "--json", "number,title,createdAt,state", + ], + capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=60, + ) + if proc.returncode != 0: + raise RuntimeError( + f"`gh issue view {number}` failed: {proc.stderr.strip()}" + ) + return json.loads(proc.stdout) + + def _parse_iso(ts: str) -> datetime: """Parse ISO 8601 returned by gh (Z-suffix) into aware datetime.""" return datetime.fromisoformat(ts.replace("Z", "+00:00")) @@ -283,6 +315,27 @@ def _cli(argv: list[str] | None = None) -> int: except RuntimeError as e: print(str(e), file=sys.stderr) return 1 + + if args.self_test: + # #18039: pour the positive-control issues into the lot. The pair + # is far behind the head and a counted window cannot see it; the + # pour adds the REAL GitHub records and the detector still has to + # find the burst. An unfetchable control issue stays out of the lot + # -> the control goes missing -> exit 2 (fail-closed). + seen = {int(d["number"]) for d in rows_raw} + for ctrl in KNOWN_POSITIVE_CONTROLS: + for n in ctrl: + if n in seen: + continue + try: + rows_raw.append(_gh_issue_view(n)) + except RuntimeError as e: + print( + f"WARNING: control issue #{n} not fetchable -- " + f"counted as removed from the lot: {e}", + file=sys.stderr, + ) + rows = [IssueRow.from_gh_dict(d) for d in rows_raw] result = detect_burst_pairs(rows, window_seconds=args.window_seconds) diff --git a/scripts/tests/test_detect_duplicate_issues.py b/scripts/tests/test_detect_duplicate_issues.py index f979c5e020..f065e348a6 100644 --- a/scripts/tests/test_detect_duplicate_issues.py +++ b/scripts/tests/test_detect_duplicate_issues.py @@ -220,22 +220,76 @@ def test_cli_exit_1_when_control_found(self): (13051, "[Lean-2] exemple", "2026-08-26T01:39:14Z"), ) from unittest import mock - with mock.patch.object(_mod, "_gh_issue_list", return_value=rows): + with mock.patch.object(_mod, "_gh_issue_list", return_value=rows), \ + mock.patch.object(_mod, "_gh_issue_view") as view: rc = _mod._cli(["--self-test", "--limit", "10"]) self.assertEqual(rc, 1) + # The pair is already in the lot -> the pour must NOT fetch again. + view.assert_not_called() def test_cli_exit_2_when_control_missing(self): """Control missing -> exit 2, even though other pairs exist - (exit 2 takes precedence over exit 1).""" + (exit 2 takes precedence over exit 1). Both control issues are + unfetchable (deleted) -> removed from the lot by definition.""" rows = self._gh_rows( (1, "foo", "2026-08-26T01:00:00Z"), (2, "foo", "2026-08-26T01:00:01Z"), ) from unittest import mock - with mock.patch.object(_mod, "_gh_issue_list", return_value=rows): + with mock.patch.object(_mod, "_gh_issue_list", return_value=rows), \ + mock.patch.object( + _mod, "_gh_issue_view", + side_effect=RuntimeError("`gh issue view` failed: gone"), + ): rc = _mod._cli(["--self-test", "--limit", "10"]) self.assertEqual(rc, 2) + def test_self_test_pours_pair_outside_counted_window(self): + """#18039 regression lock: the control pair lives BEHIND the + --limit window (repository grew past it). The pour fetches the two + real rows via `gh issue view` and adds them to the lot -- the + detector then finds the burst -> exit 1, NOT 2. This is the exact + shape that turned the daily self-test red since 2026-09-22.""" + recent = self._gh_rows( + (18000, "modern topic", "2026-09-26T10:00:00Z"), + (18001, "another topic", "2026-09-26T11:00:00Z"), + ) + control_rows = { + 13050: {"number": 13050, "title": "[Lean-2] exemple", + "createdAt": "2026-08-26T01:39:13Z", "state": "OPEN"}, + 13051: {"number": 13051, "title": "[Lean-2] exemple", + "createdAt": "2026-08-26T01:39:14Z", "state": "OPEN"}, + } + from unittest import mock + with mock.patch.object(_mod, "_gh_issue_list", return_value=recent), \ + mock.patch.object(_mod, "_gh_issue_view", + side_effect=lambda n: control_rows[n]): + rc = _mod._cli(["--self-test", "--limit", "600", + "--window-seconds", "60"]) + self.assertEqual(rc, 1) + + def test_self_test_exit_2_when_one_control_issue_removed(self): + """#18039 negative control: ONE issue of the pair is unfetchable + (removed) while the other is poured in. The lot holds a single + member -> no burst -> control missing -> exit 2.""" + recent = self._gh_rows( + (18000, "modern topic", "2026-09-26T10:00:00Z"), + ) + lone_row = {"number": 13050, "title": "[Lean-2] exemple", + "createdAt": "2026-08-26T01:39:13Z", "state": "OPEN"} + + def fake_view(number): + if number == 13050: + return lone_row + raise RuntimeError("`gh issue view` failed: gone") + + from unittest import mock + with mock.patch.object(_mod, "_gh_issue_list", return_value=recent), \ + mock.patch.object(_mod, "_gh_issue_view", side_effect=fake_view): + rc = _mod._cli(["--self-test", "--limit", "600", + "--window-seconds", "60"]) + self.assertEqual(rc, 2) + def test_blind_window_drops_control(self): """Window 0 (blind): the known pair's delta (1 s) falls outside the window -> zero pairs, control reported MISSING. This is the exact