fix(ci): parse gh api --paginate output as a single JSON document

str.splitlines() also splits on Unicode line separators (U+2028, U+2029)
which are valid inside JSON string values without escaping. When an open
GitHub issue title or body contained one of these characters, the
duplicate-issue check script broke the gh output mid-string and crashed
with 'Unterminated string ...' before the auto-close path could run.

gh api --paginate for an array endpoint already concatenates pages into
a single JSON array, so just parse the whole stdout once. Add a
regression test covering U+2028/U+2029 and the existing PR-filter path.

Co-authored-by: Krrish Dholakia <krrish-berri-2@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-06-06 09:09:15 +00:00
parent 22186f457a
commit e09bb065e4
No known key found for this signature in database
2 changed files with 95 additions and 13 deletions

View file

@ -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]

View file

@ -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"]))