diff --git a/scripts/pr_gate.py b/scripts/pr_gate.py index 927c1c3b38..7d7304867f 100644 --- a/scripts/pr_gate.py +++ b/scripts/pr_gate.py @@ -799,18 +799,39 @@ def own_job_id( `run_attempt` filters out the superseded attempts a re-run leaves in the same listing; without it the first name match could be a previous attempt's check-run, whose output nothing will ever read again. + + The listing is paged through (per_page=100): our job may sit past the + first hundred (#15749). Exhausting the pages without a match still + yields None -- the degradation is unchanged, never a verdict flip. """ api = fetch if fetch is not None else _gh_api - payload = api(f"repos/{repo}/actions/runs/{run_id}/jobs?per_page=100") - if not isinstance(payload, dict): - raise GateError(f"unexpected jobs payload for run {run_id}") - for job in payload.get("jobs") or []: - if not isinstance(job, dict): - continue - if run_attempt is not None and str(job.get("run_attempt")) != str(run_attempt): - continue - if job.get("name") == job_name: - return job.get("id") + # Paginate the jobs listing (per_page=100 cap) with the same motif as + # fetch_checks below: a run carrying more than 100 jobs would leave our + # job off page 1 and the verdict motif would silently go unpublished + # again (#15749). total_count is the authoritative stop condition; a + # partial page (< 100 jobs) is the defensive fallback that also keeps + # total_count-less payloads terminating. + page = 1 + while True: + payload = api( + f"repos/{repo}/actions/runs/{run_id}/jobs?per_page=100&page={page}" + ) + if not isinstance(payload, dict): + raise GateError(f"unexpected jobs payload for run {run_id}") + jobs = payload.get("jobs") or [] + for job in jobs: + if not isinstance(job, dict): + continue + if run_attempt is not None and str(job.get("run_attempt")) != str(run_attempt): + continue + if job.get("name") == job_name: + return job.get("id") + if len(jobs) < 100: + break + total = payload.get("total_count") + if total is not None and page * 100 >= total: + break + page += 1 return None diff --git a/scripts/tests/test_pr_gate.py b/scripts/tests/test_pr_gate.py index 66b77e6f2c..13e3aee95a 100644 --- a/scripts/tests/test_pr_gate.py +++ b/scripts/tests/test_pr_gate.py @@ -1711,6 +1711,35 @@ def test_own_job_id_is_none_when_the_name_is_absent(): ) is None +def test_own_job_id_paginates_past_the_first_hundred_jobs(): + """#15749: the jobs listing caps at per_page=100 and own_job_id did NOT + page through. A run carrying more than 100 jobs leaves OUR job off page + 1, the lookup degrades to None, and the verdict motif silently goes + unpublished again -- the exact repair #15693 made, disarmed. fetch_checks + paginates correctly in this same file; own_job_id now reuses the motif.""" + from urllib.parse import parse_qs, urlparse + + target = {"id": 777, "name": pr_gate.DEFAULT_SELF_NAME, "run_attempt": "3"} + filler = [{"id": i, "name": f"matrix {i}", "run_attempt": "3"} + for i in range(100)] + fetched = [] + + def paged(path): + q = parse_qs(urlparse(path).query) + page = int(q.get("page", ["1"])[0]) + assert q.get("per_page") == ["100"], path + fetched.append(page) + if page == 1: + return {"jobs": list(filler), "total_count": 101} + assert page == 2, f"unexpected page {page}" + return {"jobs": [target], "total_count": 101} + + got = pr_gate.own_job_id("o/r", "99", pr_gate.DEFAULT_SELF_NAME, "3", + fetch=paged) + assert got == 777 + assert fetched == [1, 2], fetched + + def test_check_run_output_is_patched_with_the_verdict(monkeypatch): """Acceptance 1 on the surface it names: the REQUIRED check carries the motive, as the exact verdict string (log == summary). PATCH, not POST --