mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
parent
22186f457a
commit
e09bb065e4
2 changed files with 95 additions and 13 deletions
19
.github/scripts/close_duplicate_issues.py
vendored
19
.github/scripts/close_duplicate_issues.py
vendored
|
|
@ -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]
|
||||
|
||||
|
||||
|
|
|
|||
89
.github/scripts/test_close_duplicate_issues.py
vendored
Normal file
89
.github/scripts/test_close_duplicate_issues.py
vendored
Normal 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"]))
|
||||
Loading…
Add table
Reference in a new issue