fix(ci): use gh api --slurp to parse paginated issues in close_duplicate_issues.py

The Check Duplicate Issues workflow crashed on every issues event with
json.decoder.JSONDecodeError. fetch_open_issues split gh api --paginate
output with splitlines() and parsed each line as JSON, but gh concatenates
pages without guaranteeing each physical line is a self-contained JSON
document, and splitlines() also breaks on unescaped Unicode line
separators (U+2028/U+2029) that GitHub returns raw inside issue bodies.
Fetch with --slurp instead, which emits one well-formed JSON array of
pages, and flatten it.

Fixes #32217
This commit is contained in:
jfu06 2026-07-06 01:10:34 -04:00
parent 5b93ba0ada
commit 069cc9a01f
2 changed files with 79 additions and 14 deletions

View file

@ -40,27 +40,17 @@ def gh(*args: str) -> str:
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)
raw = gh("api", "--paginate", "--slurp", endpoint)
pages = json.loads(raw)
issues = [issue for page in pages for issue in page]
# 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,75 @@
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
import pytest
SCRIPT_PATH = Path(__file__).resolve().parents[2] / ".github" / "scripts" / "close_duplicate_issues.py"
@pytest.fixture(scope="module")
def closer_module():
spec = importlib.util.spec_from_file_location("close_duplicate_issues", SCRIPT_PATH)
assert spec and spec.loader, f"Could not load spec for {SCRIPT_PATH}"
module = importlib.util.module_from_spec(spec)
sys.modules["close_duplicate_issues"] = module
spec.loader.exec_module(module)
return module
def _issue(number: int, title: str, body: str = "") -> dict:
return {"number": number, "title": title, "body": body}
def _fake_gh_cli(pages: list[list[dict]]):
def fake_gh(*args: str) -> str:
assert args[0] == "api"
assert "--paginate" in args
if "--slurp" in args:
return json.dumps(pages, ensure_ascii=False)
return "".join(json.dumps(page, ensure_ascii=False) for page in pages)
return fake_gh
class TestFetchOpenIssues:
def test_should_parse_multi_page_payload_with_unescaped_line_separators(self, closer_module, monkeypatch):
pages = [
[_issue(1, "first bug", body="traceback\u2028line two\u2028line three")],
[_issue(2, "second bug"), _issue(3, "third bug")],
]
monkeypatch.setattr(closer_module, "gh", _fake_gh_cli(pages))
issues = closer_module.fetch_open_issues("BerriAI/litellm")
assert [i["number"] for i in issues] == [1, 2, 3]
def test_should_request_slurp_mode(self, closer_module, monkeypatch):
calls: list[tuple[str, ...]] = []
def recording_gh(*args: str) -> str:
calls.append(args)
return json.dumps([[]])
monkeypatch.setattr(closer_module, "gh", recording_gh)
closer_module.fetch_open_issues("BerriAI/litellm")
assert len(calls) == 1
assert "--slurp" in calls[0]
def test_should_filter_out_pull_requests(self, closer_module, monkeypatch):
pages = [
[
_issue(1, "real issue"),
{**_issue(2, "a pr"), "pull_request": {"url": "https://x"}},
]
]
monkeypatch.setattr(closer_module, "gh", _fake_gh_cli(pages))
issues = closer_module.fetch_open_issues("BerriAI/litellm")
assert [i["number"] for i in issues] == [1]