From fbca99849e05839869801dc16dd4400ead2e77f9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:08:52 +0000 Subject: [PATCH] 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. --- .github/scripts/close_duplicate_issues.py | 31 ++++++------- .../scripts/test_close_duplicate_issues.py | 44 +++++++++++++++++++ 2 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 .github/scripts/test_close_duplicate_issues.py diff --git a/.github/scripts/close_duplicate_issues.py b/.github/scripts/close_duplicate_issues.py index ec522af4f88..e0fcfed45dd 100755 --- a/.github/scripts/close_duplicate_issues.py +++ b/.github/scripts/close_duplicate_issues.py @@ -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( diff --git a/.github/scripts/test_close_duplicate_issues.py b/.github/scripts/test_close_duplicate_issues.py new file mode 100644 index 00000000000..e8a733d7b0f --- /dev/null +++ b/.github/scripts/test_close_duplicate_issues.py @@ -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]