diff --git a/.gitea/scripts/ci-required-drift.py b/.gitea/scripts/ci-required-drift.py index b4039507e..d9739129b 100755 --- a/.gitea/scripts/ci-required-drift.py +++ b/.gitea/scripts/ci-required-drift.py @@ -552,23 +552,34 @@ def find_open_issue(title: str) -> dict | None: hourly; failing one cycle loudly is strictly better than silently duplicating. - Gitea issue search returns at most page=50 per page; one page is - enough as long as `[ci-drift]` issues are a tiny minority. (See - follow-up issue for Link-header pagination.) + Paginates through all open issues (limit=50 per page) until the + title is found or the result set is exhausted. Previously only one + page was fetched, causing duplicate [ci-drift] issues when the + existing tracking issue fell beyond page 1. """ - _, results = api( - "GET", - f"/repos/{OWNER}/{NAME}/issues", - query={"state": "open", "type": "issues", "limit": "50"}, - ) - if not isinstance(results, list): - raise ApiError( - f"issue search returned non-list body (got {type(results).__name__})" + page = 1 + while True: + _, results = api( + "GET", + f"/repos/{OWNER}/{NAME}/issues", + query={ + "state": "open", + "type": "issues", + "limit": "50", + "page": str(page), + }, ) - for issue in results: - if issue.get("title") == title: - return issue - return None + if not isinstance(results, list): + raise ApiError( + f"issue search returned non-list body (got {type(results).__name__})" + ) + for issue in results: + if issue.get("title") == title: + return issue + # Fewer than limit results means last page reached. + if len(results) < 50: + return None + page += 1 def render_body(branch: str, findings: list[str], debug: dict) -> str: diff --git a/tests/test_ci_required_drift.py b/tests/test_ci_required_drift.py index d9fcf889b..8e3ad9f45 100644 --- a/tests/test_ci_required_drift.py +++ b/tests/test_ci_required_drift.py @@ -584,6 +584,54 @@ def test_find_open_issue_raises_on_transient_error(drift_module, monkeypatch): drift_module.find_open_issue("[ci-drift] foo") +# -------------------------------------------------------------------------- +# Pagination: search beyond page 1 so an existing issue on any page is found +# -------------------------------------------------------------------------- +def test_find_open_issue_paginates_to_page_2(drift_module, monkeypatch): + """Issue exists on page 2 → paginate and find it.""" + target = {"number": 99, "title": "[ci-drift] foo"} + filler = [{"number": i, "title": f"other-{i}"} for i in range(1, 51)] + + class PaginatedStub: + def __init__(self): + self.calls = [] + + def __call__(self, method, path, *, body=None, query=None, expect_json=True): + self.calls.append((method, path, body, query)) + page = int((query or {}).get("page", "1")) + if page == 1: + return 200, filler + if page == 2: + return 200, [target] + return 200, [] + + stub = PaginatedStub() + monkeypatch.setattr(drift_module, "api", stub) + assert drift_module.find_open_issue("[ci-drift] foo") == target + assert len(stub.calls) == 2 + + +def test_find_open_issue_stops_at_last_page(drift_module, monkeypatch): + """No match across pages → stop when a page has <50 results.""" + filler = [{"number": i, "title": f"other-{i}"} for i in range(1, 51)] + + class PaginatedStub: + def __init__(self): + self.calls = [] + + def __call__(self, method, path, *, body=None, query=None, expect_json=True): + self.calls.append((method, path, body, query)) + page = int((query or {}).get("page", "1")) + if page == 1: + return 200, filler + return 200, [] + + stub = PaginatedStub() + monkeypatch.setattr(drift_module, "api", stub) + assert drift_module.find_open_issue("[ci-drift] foo") is None + assert len(stub.calls) == 2 + + # -------------------------------------------------------------------------- # Idempotent path: existing issue is PATCHed, NOT duplicated # --------------------------------------------------------------------------