diff --git a/.github/scripts/close_duplicate_issues.py b/.github/scripts/close_duplicate_issues.py index ec522af4f88..ceb5f0580ee 100755 --- a/.github/scripts/close_duplicate_issues.py +++ b/.github/scripts/close_duplicate_issues.py @@ -49,20 +49,13 @@ def fetch_open_issues(repo: str | None) -> list[dict]: endpoint = "repos/{owner}/{repo}/issues?state=open&per_page=100&sort=created&direction=asc" cmd = ["api", "--paginate", endpoint] - raw = gh(*cmd) - # gh --paginate concatenates JSON arrays, so we may get multiple arrays - issues = [] - for line in raw.strip().splitlines(): - line = line.strip() - if not line: - continue - parsed = json.loads(line) - if isinstance(parsed, list): - issues.extend(parsed) - else: - issues.append(parsed) + raw = gh(*cmd).strip() + if not raw: + return [] + + parsed = json.loads(raw) + issues = parsed if isinstance(parsed, list) else [parsed] - # Filter out pull requests (they also appear in the issues endpoint) return [i for i in issues if "pull_request" not in i] diff --git a/.github/scripts/test_close_duplicate_issues.py b/.github/scripts/test_close_duplicate_issues.py new file mode 100644 index 00000000000..a1fff8d15c7 --- /dev/null +++ b/.github/scripts/test_close_duplicate_issues.py @@ -0,0 +1,89 @@ +"""Tests for the duplicate-issue close script. + +Run with: python -m pytest .github/scripts/test_close_duplicate_issues.py +""" + +import json +import os +import sys +import types +from unittest.mock import patch + +import pytest + +sys.path.insert(0, os.path.dirname(__file__)) + +import close_duplicate_issues as mod + + +def _make_completed_process(stdout: str): + return types.SimpleNamespace(stdout=stdout, returncode=0, stderr="") + + +def test_fetch_open_issues_handles_unicode_line_separator(): + """Regression: U+2028 / U+2029 inside JSON strings must not break parsing. + + Python's str.splitlines() treats these characters as line breaks, but they + are valid inside JSON string values without escaping, so any line-based + parsing of gh's compact JSON output would split mid-string. + """ + issues = [ + { + "number": 1, + "title": "title with line\u2028separator", + "body": "paragraph\u2029separator and line\u2028break", + }, + {"number": 2, "title": "plain", "body": "plain"}, + ] + raw = json.dumps(issues, ensure_ascii=False) + assert "\u2028" in raw and "\u2029" in raw + + with patch.object(mod.subprocess, "run", return_value=_make_completed_process(raw)): + result = mod.fetch_open_issues("owner/repo") + + assert [i["number"] for i in result] == [1, 2] + assert result[0]["title"] == "title with line\u2028separator" + + +def test_fetch_open_issues_filters_pull_requests(): + payload = [ + {"number": 1, "title": "real issue"}, + {"number": 2, "title": "a PR", "pull_request": {"url": "..."}}, + ] + raw = json.dumps(payload) + + with patch.object(mod.subprocess, "run", return_value=_make_completed_process(raw)): + result = mod.fetch_open_issues("owner/repo") + + assert [i["number"] for i in result] == [1] + + +def test_fetch_open_issues_empty_response(): + with patch.object(mod.subprocess, "run", return_value=_make_completed_process("")): + assert mod.fetch_open_issues("owner/repo") == [] + + with patch.object( + mod.subprocess, "run", return_value=_make_completed_process("[]") + ): + assert mod.fetch_open_issues("owner/repo") == [] + + +def test_find_duplicate_normalizes_prefixes_and_case(): + target = {"number": 10, "title": "[Bug] Crash on startup"} + older = [ + {"number": 1, "title": "feature request: add streaming"}, + {"number": 2, "title": "crash on startup"}, + ] + dup = mod.find_duplicate(target, older, threshold=0.85) + assert dup is not None + assert dup["number"] == 2 + + +def test_find_duplicate_returns_none_below_threshold(): + target = {"number": 10, "title": "Streaming hangs on Anthropic"} + older = [{"number": 1, "title": "Completely unrelated topic"}] + assert mod.find_duplicate(target, older, threshold=0.85) is None + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"]))