fix(ci): parse duplicate-issue scan output with gh --slurp

Fixes a JSONDecodeError ('Unterminated string') in the auto-close
duplicate-issue workflow. gh api --paginate emits pages as concatenated
JSON arrays with no separators, so the previous line-based parsing broke
whenever an issue body contained an unescaped line-separator character
(U+2028, form feed, etc.) that str.splitlines treats as a newline.

Use gh api --paginate --slurp to collect pages into a single JSON array
and parse it in one json.loads call, then flatten and drop PRs.
This commit is contained in:
Devin AI 2026-07-11 19:08:52 +00:00
parent f604034c17
commit fbca99849e
2 changed files with 58 additions and 17 deletions

View file

@ -39,31 +39,28 @@ def gh(*args: str) -> str:
return result.stdout
def parse_slurped_issues(raw: str) -> list[dict]:
"""Flatten the pages returned by `gh api --paginate --slurp` and drop PRs."""
pages = json.loads(raw)
return [
issue
for page in pages
for issue in page
if "pull_request" not in issue
]
def fetch_open_issues(repo: str | None) -> list[dict]:
"""Fetch all open issues (excluding PRs) via gh api --paginate."""
"""Fetch all open issues (excluding PRs) via gh api --paginate --slurp."""
if repo:
endpoint = (
f"repos/{repo}/issues?state=open&per_page=100&sort=created&direction=asc"
)
else:
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)
# Filter out pull requests (they also appear in the issues endpoint)
return [i for i in issues if "pull_request" not in i]
raw = gh("api", "--paginate", "--slurp", endpoint)
return parse_slurped_issues(raw)
def close_as_duplicate(

View file

@ -0,0 +1,44 @@
import importlib.util
import json
from pathlib import Path
_MODULE_PATH = Path(__file__).with_name("close_duplicate_issues.py")
_spec = importlib.util.spec_from_file_location("close_duplicate_issues", _MODULE_PATH)
assert _spec is not None and _spec.loader is not None
close_duplicate_issues = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(close_duplicate_issues)
parse_slurped_issues = close_duplicate_issues.parse_slurped_issues
def test_parse_handles_line_separator_chars_in_body() -> None:
payload = [
[
{"number": 1, "title": "first", "body": "before\u2028after\x0cend"},
{"number": 2, "title": "second", "body": "normal"},
]
]
raw = json.dumps(payload, ensure_ascii=False)
assert "\u2028" in raw
assert len(raw.splitlines()) > 1
issues = parse_slurped_issues(raw)
assert [i["number"] for i in issues] == [1, 2]
assert issues[0]["body"] == "before\u2028after\x0cend"
def test_parse_flattens_pages_and_drops_pull_requests() -> None:
payload = [
[
{"number": 1, "title": "issue one"},
{"number": 2, "title": "a pr", "pull_request": {"url": "x"}},
],
[
{"number": 3, "title": "issue three"},
],
]
issues = parse_slurped_issues(json.dumps(payload))
assert [i["number"] for i in issues] == [1, 3]