diff --git a/scripts/ci/pr_review_merge_scheduler_core.py b/scripts/ci/pr_review_merge_scheduler_core.py index 48a5f56019..e3fa19d567 100644 --- a/scripts/ci/pr_review_merge_scheduler_core.py +++ b/scripts/ci/pr_review_merge_scheduler_core.py @@ -2845,6 +2845,26 @@ def rerun_actions_job(repo: str, job_id: str, *, dry_run: bool, action: str) -> reset_active_workflow_runs_cache() +def actions_run_has_no_jobs(repo: str, run_id: int) -> bool: + """Return whether GitHub created no jobs for a completed workflow run.""" + payload = json.loads( + run_github_read( + [ + "gh", + "api", + "--method", + "GET", + f"repos/{validate_github_repository(repo)}/actions/runs/{int(run_id)}/jobs", + "-f", + "filter=all", + "-F", + "per_page=1", + ] + ) + ) + return payload.get("total_count") == 0 + + def recover_current_head_startup_failures( repo: str, pr: dict[str, Any], @@ -2890,6 +2910,7 @@ def recover_current_head_startup_failures( if run.get("head_sha") == head_sha and run.get("status") == "completed" and run.get("conclusion") == "startup_failure" + and actions_run_has_no_jobs(repo, int(run["id"])) ] if ( retryable diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index a31a0b03bf..284641c0e7 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -4906,6 +4906,7 @@ def fake_read(args): ) monkeypatch.setattr(sched, "run_github_read", fake_read) + monkeypatch.setattr(sched, "actions_run_has_no_jobs", lambda _repo, _run_id: True) monkeypatch.setattr( sched, "restamp_pr_head_after_startup_failure", @@ -4952,6 +4953,7 @@ def test_recover_current_head_startup_failures_does_not_restamp_twice(monkeypatc } ), ) + monkeypatch.setattr(sched, "actions_run_has_no_jobs", lambda _repo, _run_id: True) monkeypatch.setattr( sched, "restamp_pr_head_after_startup_failure", @@ -4990,6 +4992,7 @@ def test_recover_current_head_startup_failures_restamps_codeql_alone( "run_github_read", lambda _args: json.dumps({"workflow_runs": [run]}), ) + monkeypatch.setattr(sched, "actions_run_has_no_jobs", lambda _repo, _run_id: True) monkeypatch.setattr( sched, "restamp_pr_head_after_startup_failure", @@ -5004,6 +5007,50 @@ def test_recover_current_head_startup_failures_restamps_codeql_alone( assert restamps == [("owner/repo", head_sha, {"dry_run": False})] +def test_recover_current_head_startup_failures_ignores_runs_with_jobs(monkeypatch): + head_sha = "a" * 40 + run = { + "id": 92, + "workflow_id": 12, + "name": "Required OpenCode Review", + "event": "pull_request_target", + "head_sha": head_sha, + "status": "completed", + "conclusion": "startup_failure", + "created_at": "2026-09-04T01:02:00Z", + } + monkeypatch.setattr( + sched, + "run_github_read", + lambda _args: json.dumps({"workflow_runs": [run]}), + ) + monkeypatch.setattr(sched, "actions_run_has_no_jobs", lambda _repo, _run_id: False) + monkeypatch.setattr( + sched, + "restamp_pr_head_after_startup_failure", + lambda *_args, **_kwargs: pytest.fail("a run with jobs is not a pre-job failure"), + ) + + assert sched.recover_current_head_startup_failures( + "owner/repo", make_pr(headRefOid=head_sha), dry_run=False + ) == [] + + +def test_actions_run_has_no_jobs_checks_every_attempt(monkeypatch): + calls = [] + monkeypatch.setattr( + sched, + "run_github_read", + lambda args: calls.append(args) or json.dumps({"total_count": 0, "jobs": []}), + ) + + assert sched.actions_run_has_no_jobs("owner/repo", 92) + assert calls == [[ + "gh", "api", "--method", "GET", "repos/owner/repo/actions/runs/92/jobs", + "-f", "filter=all", "-F", "per_page=1", + ]] + + def test_inspect_pr_recovers_startup_failure_before_other_actions(monkeypatch): monkeypatch.setenv("GITHUB_ACTIONS", "true") monkeypatch.setattr(