agents: rename buildkite-retry-job to retry-ci-job and refactor providers (#4351)

The `buildkite-retry-job` skill grew beyond Buildkite to handle GitHub
Actions workflow polling, job reruns, manual approval unblocking, and
PR check routing inside a single procedural script with overloaded
positional arguments and inconsistent error handling.

Rename the skill to `retry-ci-job` (`retry_ci_jobs.py`), replace
positional target heuristics with explicit CLI flags (`--pr`, `--url`,
`--buildkite-build`, `--buildkite-job-id`, `--github-job-id`), and
encapsulate provider operations in `BuildkiteProvider`,
`GitHubActionsProvider`, and `PrOrchestrator` with uniform failure
propagation.
diff --git a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py
index 88df0f5..9abddce 100755
--- a/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py
+++ b/.agents/skills/analyze-ci-failure/scripts/analyze_ci_failure.py
@@ -209,7 +209,7 @@
         else "🔍 **Classification**: **Code / Configuration Issue**"
     )
     fix_advice = (
-        f"Retry the failed job (`buildkite-retry-job`). {flake_reason}"
+        f"Retry the failed job (`retry-ci-job`). {flake_reason}"
         if is_flake
         else "Resolve the root cause in the relevant source / build files."
     )
diff --git a/.agents/skills/buildkite-retry-job/SKILL.md b/.agents/skills/buildkite-retry-job/SKILL.md
deleted file mode 100644
index 7acbc62..0000000
--- a/.agents/skills/buildkite-retry-job/SKILL.md
+++ /dev/null
@@ -1,58 +0,0 @@
----
-name: buildkite-retry-job
-description: Retry failed Buildkite or GitHub Actions jobs, unblock manual
-  approval steps, or rebuild full builds
----
-
-Use `scripts/retry_buildkite_jobs.py` to retry individual Buildkite or GitHub
-Actions jobs, unblock manual approval steps (such as `.bazelci/presubmit.yml`
-changes), or rebuild full builds.
-
-When a GitHub Actions job fails early (for example, `Setup Bazel` failing with
-`Error: Unable to find Bazelisk version ...` due to a transient network error)
-while sibling matrix jobs in the same workflow run are still running, GitHub
-rejects rerun requests until the parent workflow run completes. The script
-automatically waits for an in-progress workflow run to finish before triggering
-`gh run rerun`.
-
-### Retrying & Unblocking Modes
-
-1. **Default Retry & Unblock (by PR, Buildkite URL, or GitHub Actions URL)**:
-   Unblocks any blocked Buildkite manual approval steps (`bk job unblock
-   <job_id> --no-input`), retries specific failing Buildkite jobs (`bk job
-   retry <job_id>`), and reruns failing GitHub Actions jobs/runs (`gh run
-   rerun`):
-   ```bash
-   ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py \
-     <pr_number_or_url>
-   ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py \
-     "https://github.com/<owner>/<repo>/actions/runs/<run_id>/job/<job_id>"
-   ```
-
-2. **Unblock Manual Approval Steps (e.g. `.bazelci/presubmit.yml` changes)**:
-   Approves and unblocks blocked manual approval steps without retrying or
-   rebuilding other jobs:
-   ```bash
-   ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py \
-     <pr_number_or_url> --unblock
-   ```
-
-3. **Retry Specific Jobs by Name or ID**:
-   Retries matching Buildkite or GitHub Actions jobs by name/regex pattern,
-   Buildkite job UUID, or numeric GitHub Actions job ID:
-   ```bash
-   ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py \
-     <pr_number> --jobs "compile_pip_requirements"
-   ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py \
-     <pr_number> --jobs "Build linux"
-   ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py \
-     --job-id <job_uuid_or_github_job_id>
-   ```
-
-4. **Rebuild Full Build**:
-   Re-runs the entire pipeline/workflow build from scratch via `bk build
-   rebuild` or `gh run rerun`:
-   ```bash
-   ./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py \
-     <pr_number_or_url> --rebuild
-   ```
diff --git a/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py b/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py
deleted file mode 100755
index 168b1da..0000000
--- a/.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py
+++ /dev/null
@@ -1,615 +0,0 @@
-#!/usr/bin/env python3
-
-import argparse
-import json
-import re
-import subprocess
-import sys
-import time
-
-DEFAULT_GH_REPO = "bazel-contrib/rules_python"
-GH_ACTIONS_URL_RE = re.compile(
-    r"github\.com/([^/]+/[^/]+)/actions/runs/(\d+)(?:/job/(\d+))?"
-)
-
-
-def check_cli(cmd_name, install_url):
-    try:
-        subprocess.run(
-            [cmd_name, "--version"],
-            check=True,
-            stdout=subprocess.DEVNULL,
-            stderr=subprocess.DEVNULL,
-        )
-    except Exception:
-        print(
-            f"❌ Error: '{cmd_name}' CLI is not installed or not in PATH.",
-            file=sys.stderr,
-        )
-        print(f"Please install it from {install_url}", file=sys.stderr)
-        sys.exit(1)
-
-
-def get_pr_checks(pr_number):
-    check_cli("gh", "https://cli.github.com/")
-    cmd = ["gh", "pr", "checks", str(pr_number), "--json", "name,link,state,bucket"]
-    res = subprocess.run(cmd, capture_output=True, text=True)
-    out = res.stdout
-    if "[" not in out:
-        print(
-            f"❌ Error fetching PR checks for PR #{pr_number}: {res.stderr}",
-            file=sys.stderr,
-        )
-        sys.exit(1)
-    json_str = out[out.find("[") : out.rfind("]") + 1]
-    return json.loads(json_str)
-
-
-def parse_github_actions_url(target):
-    m = GH_ACTIONS_URL_RE.search(target)
-    if m:
-        return m.group(1), m.group(2), m.group(3)
-    return None
-
-
-def get_github_run_data(repo, run_id):
-    cmd = [
-        "gh",
-        "run",
-        "view",
-        str(run_id),
-        "--repo",
-        repo,
-        "--json",
-        "status,conclusion,jobs",
-    ]
-    res = subprocess.run(cmd, capture_output=True, text=True, check=True)
-    out = res.stdout
-    json_str = out[out.find("{") : out.rfind("}") + 1] if "{" in out else "{}"
-    return json.loads(json_str)
-
-
-def wait_for_github_run_completion(
-    repo, run_id, job_id=None, poll_interval=15, max_attempts=60
-):
-    for attempt in range(max_attempts):
-        try:
-            data = get_github_run_data(repo, str(run_id))
-        except Exception as e:
-            print(
-                f"âš ī¸ Warning: Could not inspect GitHub Actions run {run_id}: {e}",
-                file=sys.stderr,
-            )
-            return None
-
-        status = (data.get("status") or "").lower()
-        jobs = data.get("jobs", [])
-
-        if job_id is not None:
-            active_job_ids = {str(j.get("databaseId")) for j in jobs}
-            if str(job_id) not in active_job_ids:
-                # The job ID from an earlier attempt is no longer in the active jobs
-                # list, meaning a newer run_attempt is already queued/running/done.
-                if status != "completed" or data.get("conclusion") == "success":
-                    print(
-                        f"â„šī¸ GitHub Actions job {job_id} has already been superseded "
-                        f"by a newer attempt in run {run_id} (status: {status})."
-                    )
-                    return data
-
-        if status == "completed":
-            return data
-
-        print(
-            f"âŗ GitHub Actions run {run_id} is currently '{status}' "
-            f"(attempt {attempt + 1}/{max_attempts}); waiting {poll_interval}s "
-            f"for run to complete before rerunning..."
-        )
-        time.sleep(poll_interval)
-
-    print(
-        f"âš ī¸ Timed out waiting for GitHub Actions run {run_id} to complete.",
-        file=sys.stderr,
-    )
-    return None
-
-
-def retry_github_actions(repo, run_id, job_id=None, job_pattern=None, rebuild=False):
-    check_cli("gh", "https://cli.github.com/")
-    data = wait_for_github_run_completion(repo, run_id, job_id=job_id)
-    jobs = data.get("jobs", []) if isinstance(data, dict) else []
-
-    if rebuild:
-        print(f"🚀 Re-running entire GitHub Actions run {run_id} ({repo})...")
-        res = subprocess.run(["gh", "run", "rerun", str(run_id), "--repo", repo])
-        return res.returncode
-
-    if job_pattern:
-        matched = []
-        for j in jobs:
-            name = j.get("name", "")
-            jid = j.get("databaseId")
-            conclusion = j.get("conclusion") or j.get("status") or ""
-            if jid and (
-                re.search(job_pattern, name, re.IGNORECASE)
-                or job_pattern.lower() in name.lower()
-            ):
-                matched.append((str(jid), name, conclusion))
-
-        if not matched:
-            print(
-                f"âš ī¸ No GitHub Actions jobs found matching '{job_pattern}' in run {run_id}.",
-                file=sys.stderr,
-            )
-            return 1
-
-        rc = 0
-        for jid, name, conclusion in matched:
-            print(
-                f"🚀 Retrying GitHub Actions job '{name}' "
-                f"(ID: {jid}, conclusion: {conclusion})..."
-            )
-            res = subprocess.run(["gh", "run", "rerun", "--job", jid, "--repo", repo])
-            if res.returncode != 0:
-                print(
-                    f"âš ī¸ Single-job rerun failed for {jid}; falling back to "
-                    f"'gh run rerun {run_id} --failed'..."
-                )
-                res = subprocess.run(
-                    ["gh", "run", "rerun", str(run_id), "--failed", "--repo", repo]
-                )
-                return res.returncode
-        return rc
-
-    if job_id:
-        active_jobs_by_id = {str(j.get("databaseId")): j for j in jobs}
-        if data is not None and str(job_id) not in active_jobs_by_id:
-            status = (data.get("status") or "").lower()
-            conclusion = (data.get("conclusion") or "").lower()
-            if status != "completed" or conclusion == "success":
-                return 0
-
-        target_job = active_jobs_by_id.get(str(job_id))
-        if target_job and (target_job.get("conclusion") or "").lower() == "success":
-            print(
-                f"â„šī¸ GitHub Actions job '{target_job.get('name')}' "
-                f"(ID: {job_id}) already succeeded."
-            )
-            return 0
-
-        print(f"🚀 Retrying GitHub Actions job {job_id} in run {run_id} ({repo})...")
-        res = subprocess.run(
-            ["gh", "run", "rerun", "--job", str(job_id), "--repo", repo]
-        )
-        if res.returncode == 0:
-            return 0
-        print(
-            f"âš ī¸ Single-job rerun for {job_id} exited with {res.returncode}; "
-            f"falling back to rerunning failed jobs in run {run_id}..."
-        )
-
-    if data is not None and (data.get("conclusion") or "").lower() == "success":
-        print(f"â„šī¸ GitHub Actions run {run_id} already succeeded.")
-        return 0
-
-    print(f"🚀 Retrying failed jobs in GitHub Actions run {run_id} ({repo})...")
-    res = subprocess.run(
-        ["gh", "run", "rerun", str(run_id), "--failed", "--repo", repo]
-    )
-    return res.returncode
-
-
-def retry_github_job_by_id(job_id, repo=DEFAULT_GH_REPO):
-    check_cli("gh", "https://cli.github.com/")
-    cmd = ["gh", "api", f"repos/{repo}/actions/jobs/{job_id}"]
-    try:
-        res = subprocess.run(cmd, capture_output=True, text=True, check=True)
-        out = res.stdout
-        json_str = out[out.find("{") : out.rfind("}") + 1] if "{" in out else "{}"
-        job_data = json.loads(json_str)
-        run_id = job_data.get("run_id")
-        if run_id:
-            return retry_github_actions(repo, str(run_id), job_id=str(job_id))
-    except Exception as e:
-        print(
-            f"âš ī¸ Could not resolve run_id for GitHub job {job_id} via API: {e}",
-            file=sys.stderr,
-        )
-    print(f"🚀 Retrying GitHub Actions job ID: {job_id}...")
-    res = subprocess.run(["gh", "run", "rerun", "--job", str(job_id), "--repo", repo])
-    return res.returncode
-
-
-def normalize_build_target(target):
-    # Transforms https://buildkite.com/bazel/rules-python-python/builds/15707
-    # into (org/pipeline, 15707)
-    m = re.search(r"buildkite\.com/([^/]+/[^/]+)/builds/(\d+)", target)
-    if m:
-        return m.group(1), m.group(2)
-    parts = target.split("/")
-    if len(parts) == 3:
-        return f"{parts[0]}/{parts[1]}", parts[2]
-    elif len(parts) == 2:
-        return parts[0], parts[1]
-    return "bazel/rules-python-python", target
-
-
-def retry_job_by_uuid(job_id):
-    check_cli("bk", "https://github.com/buildkite/cli")
-    print(f"🚀 Retrying individual Buildkite job UUID: {job_id}")
-    return subprocess.run(["bk", "job", "retry", job_id])
-
-
-def unblock_manual_jobs(jobs):
-    unblocked = []
-    for j in jobs:
-        jstate = j.get("state", "")
-        is_manual_block = j.get("type") == "manual" or j.get("unblockable") is True
-        jid = j.get("id")
-        if jid and jstate in ("blocked", "blocked_failed") and is_manual_block:
-            jname = j.get("name") or j.get("label") or jid
-            print(f"🔓 Unblocking manual approval job '{jname}' (ID: {jid})...")
-            subprocess.run(["bk", "job", "unblock", jid, "--no-input"])
-            unblocked.append(j)
-    return unblocked
-
-
-def retry_jobs_by_name(pipeline, build_number, job_pattern, exit_on_missing=True):
-    check_cli("bk", "https://github.com/buildkite/cli")
-    cmd = ["bk", "build", "view", str(build_number), "-p", pipeline, "--json"]
-    try:
-        res = subprocess.run(cmd, capture_output=True, text=True, check=True)
-        data = json.loads(res.stdout)
-        jobs = data.get("jobs", [])
-        matched = []
-        for j in jobs:
-            name = j.get("name", "") or j.get("label", "")
-            state = j.get("state", "")
-            jid = j.get("id")
-            if jid and (
-                re.search(job_pattern, name, re.IGNORECASE)
-                or job_pattern.lower() in name.lower()
-            ):
-                matched.append((jid, name, state))
-
-        if not matched:
-            if exit_on_missing:
-                print(
-                    f"âš ī¸ No jobs found matching pattern '{job_pattern}' in build #{build_number}",
-                    file=sys.stderr,
-                )
-                sys.exit(1)
-            return False
-
-        for jid, name, state in matched:
-            print(f"🚀 Retrying job '{name}' (ID: {jid}, status: {state})...")
-            subprocess.run(["bk", "job", "retry", jid])
-        return True
-    except Exception as e:
-        print(f"❌ Error fetching build jobs: {e}", file=sys.stderr)
-        if exit_on_missing:
-            sys.exit(1)
-        return False
-
-
-def retry_buildkite_build(
-    pipeline,
-    build_number,
-    job_name=None,
-    unblock=False,
-    rebuild=False,
-    rebuild_if_no_failed_jobs=True,
-):
-    check_cli("bk", "https://github.com/buildkite/cli")
-    if job_name:
-        return retry_jobs_by_name(pipeline, build_number, job_name)
-    elif unblock:
-        cmd = [
-            "bk",
-            "build",
-            "view",
-            str(build_number),
-            "-p",
-            pipeline,
-            "--json",
-        ]
-        res = subprocess.run(cmd, capture_output=True, text=True, check=True)
-        data = json.loads(res.stdout)
-        unblocked = unblock_manual_jobs(data.get("jobs", []))
-        if not unblocked:
-            print(
-                f"â„šī¸ No blocked manual approval jobs found in build #{build_number}.",
-                file=sys.stderr,
-            )
-        return True
-    elif rebuild:
-        print(f"🚀 Rebuilding entire build #{build_number} for pipeline: {pipeline}")
-        res = subprocess.run(
-            ["bk", "build", "rebuild", build_number, "-p", pipeline, "--yes"]
-        )
-        return res.returncode == 0
-    else:
-        cmd = [
-            "bk",
-            "build",
-            "view",
-            str(build_number),
-            "-p",
-            pipeline,
-            "--json",
-        ]
-        try:
-            res = subprocess.run(cmd, capture_output=True, text=True, check=True)
-            data = json.loads(res.stdout)
-            jobs = data.get("jobs", [])
-            unblocked_jobs = unblock_manual_jobs(jobs)
-            failed_jobs = [
-                j
-                for j in jobs
-                if j.get("state") in ("failed", "broken", "timed_out")
-                and j.get("id")
-                and not j.get("retried_in_job_uuid")
-                and not j.get("retried_at")
-            ]
-            if failed_jobs:
-                for j in failed_jobs:
-                    jid = j["id"]
-                    jname = j.get("name") or j.get("label") or jid
-                    print(f"🚀 Retrying failed job '{jname}' (ID: {jid})...")
-                    subprocess.run(["bk", "job", "retry", jid])
-                return True
-            elif unblocked_jobs:
-                return True
-            elif rebuild_if_no_failed_jobs:
-                print(
-                    f"🚀 Rebuilding entire build #{build_number} for pipeline: {pipeline}"
-                )
-                res = subprocess.run(
-                    [
-                        "bk",
-                        "build",
-                        "rebuild",
-                        build_number,
-                        "-p",
-                        pipeline,
-                        "--yes",
-                    ]
-                )
-                return res.returncode == 0
-            return False
-        except Exception:
-            if rebuild_if_no_failed_jobs:
-                print(
-                    f"🚀 Rebuilding entire build #{build_number} for pipeline: {pipeline}"
-                )
-                res = subprocess.run(
-                    [
-                        "bk",
-                        "build",
-                        "rebuild",
-                        build_number,
-                        "-p",
-                        pipeline,
-                        "--yes",
-                    ]
-                )
-                return res.returncode == 0
-            return False
-
-
-def handle_pr_target(pr_number, job_name=None, unblock=False, rebuild=False):
-    print(f"🔍 Inspecting PR #{pr_number} checks via gh...")
-    checks = get_pr_checks(pr_number)
-
-    bk_checks = []
-    gh_checks = []
-    for c in checks:
-        link = c.get("link", "")
-        if "buildkite.com" in link:
-            bk_checks.append(c)
-        elif parse_github_actions_url(link):
-            gh_checks.append(c)
-
-    if unblock:
-        if not bk_checks:
-            print(f"â„šī¸ No Buildkite checks found for PR #{pr_number}.", file=sys.stderr)
-            return 0
-        for c in bk_checks:
-            pipeline, build_number = normalize_build_target(c["link"].split("#")[0])
-            retry_buildkite_build(pipeline, build_number, unblock=True)
-        return 0
-
-    if job_name:
-        matched_any = False
-        matched_gh_runs = {}
-        for c in gh_checks:
-            cname = c.get("name", "")
-            parsed = parse_github_actions_url(c.get("link", ""))
-            if parsed and (
-                re.search(job_name, cname, re.IGNORECASE)
-                or job_name.lower() in cname.lower()
-            ):
-                repo, run_id, job_id = parsed
-                matched_gh_runs.setdefault((repo, run_id), []).append(job_id)
-
-        for (repo, run_id), job_ids in matched_gh_runs.items():
-            matched_any = True
-            if len(job_ids) == 1 and job_ids[0]:
-                retry_github_actions(repo, run_id, job_id=job_ids[0])
-            else:
-                retry_github_actions(repo, run_id, job_pattern=job_name)
-
-        for c in bk_checks:
-            pipeline, build_number = normalize_build_target(c["link"].split("#")[0])
-            if retry_jobs_by_name(
-                pipeline, build_number, job_name, exit_on_missing=False
-            ):
-                matched_any = True
-
-        if not matched_any:
-            print(
-                f"âš ī¸ No GitHub Actions or Buildkite jobs found matching "
-                f"'{job_name}' for PR #{pr_number}.",
-                file=sys.stderr,
-            )
-            return 1
-        return 0
-
-    # Retry failed GitHub Actions checks (or rebuild all failed runs if --rebuild)
-    failed_gh_runs = {}
-    for c in gh_checks:
-        state = (c.get("state") or "").upper()
-        bucket = (c.get("bucket") or "").lower()
-        is_failed = (
-            state in ("FAILURE", "FAIL", "CANCELLED", "TIMED_OUT", "STARTUP_FAILURE")
-            or bucket == "fail"
-        )
-        if is_failed or rebuild:
-            parsed = parse_github_actions_url(c.get("link", ""))
-            if parsed:
-                repo, run_id, job_id = parsed
-                if is_failed:
-                    failed_gh_runs.setdefault((repo, run_id), []).append(job_id)
-
-    gh_retried = False
-    for (repo, run_id), job_ids in failed_gh_runs.items():
-        gh_retried = True
-        if rebuild:
-            retry_github_actions(repo, run_id, rebuild=True)
-        elif len(job_ids) == 1 and job_ids[0]:
-            retry_github_actions(repo, run_id, job_id=job_ids[0])
-        else:
-            retry_github_actions(repo, run_id)
-
-    # Check Buildkite builds
-    for c in bk_checks:
-        pipeline, build_number = normalize_build_target(c["link"].split("#")[0])
-        state = (c.get("state") or "").upper()
-        bucket = (c.get("bucket") or "").lower()
-        bk_failed = (
-            state in ("FAILURE", "FAIL", "CANCELLED", "TIMED_OUT") or bucket == "fail"
-        )
-        retry_buildkite_build(
-            pipeline,
-            build_number,
-            rebuild=rebuild,
-            rebuild_if_no_failed_jobs=(bk_failed and not gh_retried),
-        )
-
-    if not bk_checks and not gh_retried:
-        print(
-            f"â„šī¸ No failing GitHub Actions or Buildkite checks found for PR #{pr_number}."
-        )
-    return 0
-
-
-def main():
-    parser = argparse.ArgumentParser(
-        description=(
-            "Retry failed Buildkite or GitHub Actions jobs individually, "
-            "unblock Buildkite approval steps, or rebuild entire builds."
-        )
-    )
-    parser.add_argument(
-        "args",
-        nargs="*",
-        help=(
-            "Target build (PR#, Buildkite URL, GitHub Actions run/job URL, "
-            "or org/pipeline/build)"
-        ),
-    )
-    parser.add_argument(
-        "--jobs",
-        "--job-name",
-        dest="job_name",
-        help="Specific job name or regex pattern to retry individually",
-    )
-    parser.add_argument(
-        "--job-id",
-        dest="job_id",
-        help=(
-            "Specific Buildkite job UUID or GitHub Actions job ID to retry individually"
-        ),
-    )
-    parser.add_argument(
-        "--rebuild",
-        action="store_true",
-        help="Rebuild the entire build afresh",
-    )
-    parser.add_argument(
-        "--unblock",
-        action="store_true",
-        help=(
-            "Unblock manual approval steps (e.g. .bazelci/presubmit.yml "
-            "changes) via 'bk job unblock'"
-        ),
-    )
-    args = parser.parse_args()
-
-    if args.job_id:
-        gh_parsed = parse_github_actions_url(args.job_id)
-        if gh_parsed:
-            repo, run_id, job_id = gh_parsed
-            sys.exit(
-                retry_github_actions(
-                    repo,
-                    run_id,
-                    job_id=job_id,
-                    job_pattern=args.job_name,
-                    rebuild=args.rebuild,
-                )
-            )
-        if args.job_id.isdigit():
-            sys.exit(retry_github_job_by_id(args.job_id))
-        res = retry_job_by_uuid(args.job_id)
-        sys.exit(res.returncode)
-
-    if len(args.args) == 3:
-        target = f"{args.args[0]}/{args.args[1]}/{args.args[2]}"
-    elif len(args.args) == 1:
-        target = args.args[0]
-    else:
-        print(
-            "❌ Error: Invalid arguments. Provide a single target "
-            "(PR#, Buildkite URL, GitHub Actions URL, or org/pipeline/build).",
-            file=sys.stderr,
-        )
-        sys.exit(1)
-
-    gh_parsed = parse_github_actions_url(target)
-    if gh_parsed:
-        repo, run_id, job_id = gh_parsed
-        sys.exit(
-            retry_github_actions(
-                repo,
-                run_id,
-                job_id=job_id,
-                job_pattern=args.job_name,
-                rebuild=args.rebuild,
-            )
-        )
-
-    if target.isdigit() and len(target) < 10:
-        sys.exit(
-            handle_pr_target(
-                target,
-                job_name=args.job_name,
-                unblock=args.unblock,
-                rebuild=args.rebuild,
-            )
-        )
-
-    pipeline, build_number = normalize_build_target(target)
-    ok = retry_buildkite_build(
-        pipeline,
-        build_number,
-        job_name=args.job_name,
-        unblock=args.unblock,
-        rebuild=args.rebuild,
-        rebuild_if_no_failed_jobs=True,
-    )
-    sys.exit(0 if ok else 1)
-
-
-if __name__ == "__main__":
-    main()
diff --git a/.agents/skills/merge-pr/SKILL.md b/.agents/skills/merge-pr/SKILL.md
index 99f8176..96304d1 100644
--- a/.agents/skills/merge-pr/SKILL.md
+++ b/.agents/skills/merge-pr/SKILL.md
@@ -22,17 +22,17 @@
 3. **Leverage Existing CI Skills**:
    - Have the subagent use the **`monitor-ci-results`** skill to watch for CI
      check failures and generate analysis reports.
-   - Have the subagent use the **`buildkite-retry-job`** skill
-     (`retry_buildkite_jobs.py <pr_number>`) to automatically retry any
+   - Have the subagent use the **`retry-ci-job`** skill
+     (`retry_ci_jobs.py --pr <pr_number>`) to automatically retry any
      transient network flakes (e.g., HTTP 504 gateway timeouts, downloader
      errors, `setup-bazel` `Unable to find Bazelisk version` errors) and
      unblock manual approval steps.
    - **Blocked Builds (`.bazelci/presubmit.yml` Approval)**: When
      `.bazelci/presubmit.yml` is modified, Buildkite blocks the build until
      manually approved. Approve and unblock it immediately via
-     `./.agents/skills/buildkite-retry-job/scripts/retry_buildkite_jobs.py
-     <pr_number> --unblock` (or `bk job unblock <job_id> --no-input`) so CI does
-     not sit blocked indefinitely.
+     `./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py
+     --pr <pr_number> --unblock` (or `bk job unblock <job_id> --no-input`) so CI
+     does not sit blocked indefinitely.
    - **Soft-Failing Jobs**: Experimental Buildkite jobs (e.g. `*rolling*`
      Bazel) are non-blocking soft failures; do not treat them as merge blockers.
    - When the PR is queued, actively discover the merge queue branch via
diff --git a/.agents/skills/monitor-ci-results/SKILL.md b/.agents/skills/monitor-ci-results/SKILL.md
index 29cba5d..c92bdda 100644
--- a/.agents/skills/monitor-ci-results/SKILL.md
+++ b/.agents/skills/monitor-ci-results/SKILL.md
@@ -38,7 +38,7 @@
    `bk job unblock <job_id> --no-input` so CI does not sit blocked. If
    automatic unblocking fails, it notifies the conversation via
    `agentapi send-message`; unblock Buildkite jobs using the
-   `buildkite-retry-job` skill (`retry_buildkite_jobs.py <pr_number>
+   `retry-ci-job` skill (`retry_ci_jobs.py --pr <pr_number>
    --unblock`).
 3. **Failure Reporting**: When any GitHub check or Buildkite job completes
    with errors, `monitor_remote_ci.py` dispatches a high-priority notification
@@ -55,6 +55,6 @@
    autonomously** launch a separate subagent using `invoke_subagent` with the
    role "CI Job Retrier" and the prompt template in
    `.agents/skills/monitor-ci-results/retry-job-prompt.md` to run the
-   `buildkite-retry-job` skill for `<pr_number>`. Do not execute the script
+   `retry-ci-job` skill for `<pr_number>`. Do not execute the script
    directly; use the subagent and skill orchestration instead. Continue
    monitoring without pausing to ask the user.
diff --git a/.agents/skills/monitor-ci-results/retry-job-prompt.md b/.agents/skills/monitor-ci-results/retry-job-prompt.md
index 9d948b2..f7c3382 100644
--- a/.agents/skills/monitor-ci-results/retry-job-prompt.md
+++ b/.agents/skills/monitor-ci-results/retry-job-prompt.md
@@ -1,5 +1,5 @@
-Use the `buildkite-retry-job` skill to retry the failed Buildkite or GitHub
-Actions job(s) for PR {pr_number}.
-Follow the instructions in `.agents/skills/buildkite-retry-job/SKILL.md` to
-retry the failing jobs using the skill.
+Use the `retry-ci-job` skill to retry the failed Buildkite or GitHub Actions
+job(s) for PR {pr_number}.
+Follow the instructions in `.agents/skills/retry-ci-job/SKILL.md` to retry the
+failing jobs using the skill.
 Once the retry request completes, report back to the parent conversation.
diff --git a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py
index 669d117..e8d3a02 100755
--- a/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py
+++ b/.agents/skills/monitor-ci-results/scripts/monitor_remote_ci.py
@@ -260,7 +260,9 @@
                                     f"âš ī¸ Remote CI Buildkite Job '{jname}' is blocked!\n\n"
                                     f"Build ID: {build_id} | Job ID: {jid}\n"
                                     f"Log URL: {log_url}\n\n"
-                                    f"Action required: Unblock the job using the buildkite-retry-job skill (`retry_buildkite_jobs.py {args.pr} --unblock`)."
+                                    "Action required: Unblock the job using the "
+                                    f"retry-ci-job skill (`retry_ci_jobs.py --pr {args.pr} "
+                                    "--unblock`)."
                                 )
                                 subprocess.run(
                                     [
diff --git a/.agents/skills/retry-ci-job/SKILL.md b/.agents/skills/retry-ci-job/SKILL.md
new file mode 100644
index 0000000..6737ebd
--- /dev/null
+++ b/.agents/skills/retry-ci-job/SKILL.md
@@ -0,0 +1,62 @@
+---
+name: retry-ci-job
+description: Retry failed Buildkite or GitHub Actions jobs, unblock manual
+  approval steps, or rebuild full builds
+---
+
+Use `scripts/retry_ci_jobs.py` to retry individual Buildkite or GitHub Actions
+jobs, unblock manual approval steps (such as `.bazelci/presubmit.yml` changes),
+or rebuild full builds.
+
+When a GitHub Actions job fails early (for example, `Setup Bazel` failing with
+`Error: Unable to find Bazelisk version ...` due to a transient network error)
+while sibling matrix jobs in the same workflow run are still running, GitHub
+rejects rerun requests until the parent workflow run completes. The script
+automatically waits for an in-progress workflow run to finish before triggering
+`gh run rerun`.
+
+### Retrying & Unblocking Modes
+
+1. **Default Retry & Unblock (by PR, URL, or Buildkite Build)**:
+   Unblocks any blocked Buildkite manual approval steps (`bk job unblock
+   <job_id> --no-input`), retries specific failing Buildkite jobs (`bk job
+   retry <job_id>`), and reruns failing GitHub Actions jobs/runs (`gh run
+   rerun`):
+   ```bash
+   ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py \
+     --pr <pr_number>
+   ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py \
+     --url "https://github.com/<owner>/<repo>/actions/runs/<run_id>/job/<id>"
+   ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py \
+     --buildkite-build "<org>/<pipeline>/<build_number>"
+   ```
+
+2. **Unblock Manual Approval Steps (e.g. `.bazelci/presubmit.yml` changes)**:
+   Approves and unblocks blocked manual approval steps without retrying or
+   rebuilding other jobs:
+   ```bash
+   ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py \
+     --pr <pr_number> --unblock
+   ```
+
+3. **Retry Specific Jobs by Name or ID**:
+   Retries matching Buildkite or GitHub Actions jobs by name/regex pattern,
+   Buildkite job UUID, or numeric GitHub Actions job ID:
+   ```bash
+   ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py \
+     --pr <pr_number> --jobs "compile_pip_requirements"
+   ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py \
+     --pr <pr_number> --jobs "Build linux"
+   ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py \
+     --buildkite-job-id <job_uuid>
+   ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py \
+     --github-job-id <github_job_id>
+   ```
+
+4. **Rebuild Full Build**:
+   Re-runs the entire pipeline/workflow build from scratch via `bk build
+   rebuild` or `gh run rerun`:
+   ```bash
+   ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py \
+     --pr <pr_number> --rebuild
+   ```
diff --git a/.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py b/.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py
new file mode 100755
index 0000000..adabc2c
--- /dev/null
+++ b/.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py
@@ -0,0 +1,957 @@
+#!/usr/bin/env python3
+"""Retry failed Buildkite or GitHub Actions CI jobs, unblock manual steps, or rebuild builds.
+
+Target input flags (mutually exclusive, one required):
+  - `--pr <number>`: Pull request number (e.g. `--pr 4320`)
+  - `--url <url>`: Buildkite build URL (`https://buildkite.com/<org>/<pipeline>/builds/<number>`)
+    or GitHub Actions run/job URL
+    (`https://github.com/<owner>/<repo>/actions/runs/<run_id>[/job/<job_id>]`)
+  - `--buildkite-build <org/pipeline/number>`: Buildkite build identifier
+  - `--buildkite-job-id <uuid>`: Individual Buildkite job UUID
+  - `--github-job-id <id>`: Individual GitHub Actions job ID
+
+Usage:
+  ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py --pr 4320
+  ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py --pr 4320 --unblock
+  ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py --pr 4320 --jobs "Build linux"
+  ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py --github-job-id 1234567890
+  ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py --buildkite-job-id 0190a1b2-c3d4
+  ./.agents/skills/retry-ci-job/scripts/retry_ci_jobs.py --pr 4320 --rebuild
+"""
+
+from __future__ import annotations
+
+import argparse
+import enum
+import json
+import re
+import subprocess
+import sys
+import time
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+
+# ==============================================================================
+# 1. Global variables / constants
+# ==============================================================================
+
+_DEFAULT_GH_REPO = "bazel-contrib/rules_python"
+_DEFAULT_BK_PIPELINE = "bazel/rules-python-python"
+
+_GH_ACTIONS_URL_RE = re.compile(
+    r"github\.com/([^/]+/[^/]+)/actions/runs/(\d+)(?:/job/(\d+))?"
+)
+_BK_BUILD_URL_RE = re.compile(r"buildkite\.com/([^/]+/[^/]+)/builds/(\d+)")
+
+# GitHub rejects `gh run rerun` while sibling matrix jobs in the workflow run are
+# still executing; poll up to 15 minutes (60 * 15s) for the parent run to finish.
+_GH_POLL_INTERVAL_SECONDS = 15
+_GH_MAX_POLL_ATTEMPTS = 60
+
+_FAILED_CHECK_STATES = frozenset(
+    [
+        # keep-sorted start
+        "CANCELLED",
+        "FAIL",
+        "FAILURE",
+        "STARTUP_FAILURE",
+        "TIMED_OUT",
+        # keep-sorted end
+    ]
+)
+
+_FAILED_BK_JOB_STATES = frozenset(
+    [
+        # keep-sorted start
+        "broken",
+        "failed",
+        "timed_out",
+        # keep-sorted end
+    ]
+)
+
+_BLOCKED_BK_JOB_STATES = frozenset(
+    [
+        # keep-sorted start
+        "blocked",
+        "blocked_failed",
+        # keep-sorted end
+    ]
+)
+
+
+# ==============================================================================
+# 2. Flags / CLI arguments
+# ==============================================================================
+
+
+def _parse_args(argv: Sequence[str]) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description=(
+            "Retry failed Buildkite or GitHub Actions jobs individually, "
+            "unblock Buildkite approval steps, or rebuild entire builds."
+        )
+    )
+    target_group = parser.add_mutually_exclusive_group(required=True)
+    target_group.add_argument(
+        "--pr",
+        type=int,
+        default=0,
+        help="Pull request number to inspect and retry",
+    )
+    target_group.add_argument(
+        "--url",
+        default="",
+        help="Buildkite build URL or GitHub Actions workflow run/job URL",
+    )
+    target_group.add_argument(
+        "--buildkite-build",
+        default="",
+        dest="buildkite_build",
+        help="Buildkite build identifier (<org>/<pipeline>/<number> or <number>)",
+    )
+    target_group.add_argument(
+        "--buildkite-job-id",
+        default="",
+        dest="buildkite_job_id",
+        help="Specific Buildkite job UUID to retry individually",
+    )
+    target_group.add_argument(
+        "--github-job-id",
+        default="",
+        dest="github_job_id",
+        help="Specific GitHub Actions job ID to retry individually",
+    )
+
+    mode_group = parser.add_mutually_exclusive_group()
+    mode_group.add_argument(
+        "--jobs",
+        default="",
+        dest="job_name",
+        help="Specific job name or regex pattern to retry individually",
+    )
+    mode_group.add_argument(
+        "--rebuild",
+        action="store_true",
+        help="Rebuild the entire build afresh",
+    )
+    mode_group.add_argument(
+        "--unblock",
+        action="store_true",
+        help=(
+            "Unblock manual approval steps (e.g. .bazelci/presubmit.yml "
+            "changes) via 'bk job unblock'"
+        ),
+    )
+    return parser.parse_args(list(argv))
+
+
+# ==============================================================================
+# 3. Functions
+# ==============================================================================
+
+
+def _check_cli(cmd_name: str, install_url: str) -> None:
+    try:
+        subprocess.run(
+            [cmd_name, "--version"],
+            check=True,
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+        )
+    except (OSError, subprocess.SubprocessError) as e:
+        raise CiRetryError(
+            f"❌ Error: '{cmd_name}' CLI is not installed or not in PATH.\n"
+            f"Please install it from {install_url}"
+        ) from e
+
+
+def _extract_json_object(raw_output: str) -> dict[str, object]:
+    if "{" not in raw_output:
+        return {}
+    json_str = raw_output[raw_output.find("{") : raw_output.rfind("}") + 1]
+    parsed = json.loads(json_str)
+    if isinstance(parsed, dict):
+        return parsed
+    return {}
+
+
+def _extract_json_array(raw_output: str) -> list[dict[str, object]]:
+    if "[" not in raw_output:
+        return []
+    json_str = raw_output[raw_output.find("[") : raw_output.rfind("]") + 1]
+    parsed = json.loads(json_str)
+    if isinstance(parsed, list):
+        return parsed
+    return []
+
+
+def _get_pr_checks(pr_number: int) -> list[dict[str, object]]:
+    _check_cli("gh", "https://cli.github.com/")
+    cmd = [
+        "gh",
+        "pr",
+        "checks",
+        str(pr_number),
+        "--json",
+        "name,link,state,bucket",
+    ]
+    res = subprocess.run(cmd, capture_output=True, text=True)
+    if "[" not in res.stdout:
+        raise CiRetryError(
+            f"❌ Error fetching PR checks for PR #{pr_number}: {res.stderr.strip()}"
+        )
+    return _extract_json_array(res.stdout)
+
+
+def _matches_job_pattern(job_name: str, pattern: str) -> bool:
+    if pattern.lower() in job_name.lower():
+        return True
+    try:
+        return bool(re.search(pattern, job_name, re.IGNORECASE))
+    except re.error:
+        return False
+
+
+def _is_check_failed(check: Mapping[str, object]) -> bool:
+    state = str(check.get("state") or "").upper()
+    bucket = str(check.get("bucket") or "").lower()
+    return state in _FAILED_CHECK_STATES or bucket == "fail"
+
+
+def _parse_github_actions_url(target: str) -> GitHubRunTarget | None:
+    match = _GH_ACTIONS_URL_RE.search(target)
+    if not match:
+        return None
+    return GitHubRunTarget(
+        repo=match.group(1),
+        run_id=match.group(2),
+        job_id=match.group(3) or "",
+    )
+
+
+def _parse_buildkite_url(url: str) -> BuildkiteBuildTarget | None:
+    clean_url = url.split("#")[0]
+    match = _BK_BUILD_URL_RE.search(clean_url)
+    if not match:
+        return None
+    return BuildkiteBuildTarget(
+        pipeline=match.group(1),
+        build_number=match.group(2),
+    )
+
+
+def _parse_buildkite_build_spec(spec: str) -> BuildkiteBuildTarget:
+    clean_spec = spec.split("#")[0]
+    parts = clean_spec.split("/")
+    if len(parts) == 3:
+        return BuildkiteBuildTarget(
+            pipeline=f"{parts[0]}/{parts[1]}",
+            build_number=parts[2],
+        )
+    if len(parts) == 2:
+        return BuildkiteBuildTarget(
+            pipeline=parts[0],
+            build_number=parts[1],
+        )
+    if len(parts) == 1 and clean_spec:
+        return BuildkiteBuildTarget(
+            pipeline=_DEFAULT_BK_PIPELINE,
+            build_number=clean_spec,
+        )
+    raise CiRetryError(
+        f"❌ Error: Invalid Buildkite build identifier '{spec}'. "
+        "Expected '<org>/<pipeline>/<number>' or '<number>'."
+    )
+
+
+def _resolve_mode(args: argparse.Namespace) -> OperationMode:
+    if args.unblock:
+        return OperationMode.UNBLOCK
+    if args.rebuild:
+        return OperationMode.REBUILD
+    if args.job_name:
+        return OperationMode.MATCH_JOBS
+    return OperationMode.RETRY_FAILED
+
+
+def _resolve_target(
+    args: argparse.Namespace,
+) -> (
+    PrTarget
+    | BuildkiteBuildTarget
+    | BuildkiteJobTarget
+    | GitHubRunTarget
+    | GitHubJobTarget
+):
+    if args.pr:
+        return PrTarget(pr_number=args.pr)
+    if args.buildkite_job_id:
+        return BuildkiteJobTarget(job_uuid=args.buildkite_job_id)
+    if args.github_job_id:
+        return GitHubJobTarget(repo=_DEFAULT_GH_REPO, job_id=args.github_job_id)
+    if args.buildkite_build:
+        return _parse_buildkite_build_spec(args.buildkite_build)
+    if args.url:
+        gh_run_target = _parse_github_actions_url(args.url)
+        if gh_run_target:
+            return gh_run_target
+        bk_build_target = _parse_buildkite_url(args.url)
+        if bk_build_target:
+            return bk_build_target
+        raise CiRetryError(
+            f"❌ Error: Unsupported URL '{args.url}'. "
+            "Expected a Buildkite build URL or GitHub Actions run/job URL."
+        )
+    raise CiRetryError("❌ Error: No target specified.")
+
+
+# ==============================================================================
+# 4. Classes
+# ==============================================================================
+
+
+class CiRetryError(Exception):
+    """Raised when CLI prerequisites or target arguments are invalid."""
+
+
+class OperationMode(enum.Enum):
+    """Execution mode for retrying, unblocking, or rebuilding CI targets."""
+
+    RETRY_FAILED = "retry_failed"
+    UNBLOCK = "unblock"
+    REBUILD = "rebuild"
+    MATCH_JOBS = "match_jobs"
+
+
+@dataclass(frozen=True)
+class PrTarget:
+    """Pull request number target."""
+
+    pr_number: int
+
+
+@dataclass(frozen=True)
+class BuildkiteBuildTarget:
+    """Buildkite pipeline and build number target."""
+
+    pipeline: str
+    build_number: str
+
+
+@dataclass(frozen=True)
+class BuildkiteJobTarget:
+    """Individual Buildkite job UUID target."""
+
+    job_uuid: str
+
+
+@dataclass(frozen=True)
+class GitHubRunTarget:
+    """GitHub Actions workflow run (and optional job ID) target."""
+
+    repo: str
+    run_id: str
+    job_id: str = ""
+
+
+@dataclass(frozen=True)
+class GitHubJobTarget:
+    """Standalone numeric GitHub Actions job ID target."""
+
+    repo: str
+    job_id: str
+
+
+class BuildkiteProvider:
+    """Executes Buildkite job retries, manual step unblocks, and build rebuilds."""
+
+    def _ensure_cli(self) -> None:
+        _check_cli("bk", "https://github.com/buildkite/cli")
+
+    def _view_build(self, target: BuildkiteBuildTarget) -> list[dict[str, object]]:
+        self._ensure_cli()
+        cmd = [
+            "bk",
+            "build",
+            "view",
+            target.build_number,
+            "-p",
+            target.pipeline,
+            "--json",
+        ]
+        res = subprocess.run(cmd, capture_output=True, text=True, check=True)
+        data = json.loads(res.stdout)
+        jobs = data.get("jobs", []) if isinstance(data, dict) else []
+        if isinstance(jobs, list):
+            return jobs
+        return []
+
+    def retry_job_by_uuid(self, target: BuildkiteJobTarget) -> bool:
+        self._ensure_cli()
+        print(f"🚀 Retrying individual Buildkite job UUID: {target.job_uuid}")
+        res = subprocess.run(["bk", "job", "retry", target.job_uuid])
+        return res.returncode == 0
+
+    def rebuild_build(self, target: BuildkiteBuildTarget) -> bool:
+        self._ensure_cli()
+        print(
+            f"🚀 Rebuilding entire build #{target.build_number} "
+            f"for pipeline: {target.pipeline}"
+        )
+        res = subprocess.run(
+            [
+                "bk",
+                "build",
+                "rebuild",
+                target.build_number,
+                "-p",
+                target.pipeline,
+                "--yes",
+            ]
+        )
+        return res.returncode == 0
+
+    def unblock_manual_jobs(
+        self, jobs: Sequence[Mapping[str, object]]
+    ) -> tuple[list[Mapping[str, object]], bool]:
+        unblocked: list[Mapping[str, object]] = []
+        all_ok = True
+        for job in jobs:
+            jstate = str(job.get("state") or "")
+            is_manual_block = (
+                job.get("type") == "manual" or job.get("unblockable") is True
+            )
+            jid = str(job.get("id") or "")
+            if jid and jstate in _BLOCKED_BK_JOB_STATES and is_manual_block:
+                jname = str(job.get("name") or job.get("label") or jid)
+                print(f"🔓 Unblocking manual approval job '{jname}' (ID: {jid})...")
+                res = subprocess.run(["bk", "job", "unblock", jid, "--no-input"])
+                if res.returncode != 0:
+                    all_ok = False
+                unblocked.append(job)
+        return unblocked, all_ok
+
+    def retry_jobs_by_name(
+        self,
+        target: BuildkiteBuildTarget,
+        job_pattern: str,
+        fail_on_missing: bool = True,
+    ) -> tuple[bool, bool]:
+        """Retries Buildkite jobs matching `job_pattern`.
+
+        Returns:
+            Tuple of `(matched_any, all_succeeded)`.
+        """
+        try:
+            jobs = self._view_build(target)
+        except (subprocess.SubprocessError, json.JSONDecodeError) as e:
+            print(f"❌ Error fetching build jobs: {e}", file=sys.stderr)
+            return False, False
+
+        matched: list[tuple[str, str, str]] = []
+        for job in jobs:
+            name = str(job.get("name") or job.get("label") or "")
+            state = str(job.get("state") or "")
+            jid = str(job.get("id") or "")
+            if jid and _matches_job_pattern(name, job_pattern):
+                matched.append((jid, name, state))
+
+        if not matched:
+            if fail_on_missing:
+                print(
+                    f"âš ī¸ No jobs found matching pattern '{job_pattern}' "
+                    f"in build #{target.build_number}",
+                    file=sys.stderr,
+                )
+            return False, not fail_on_missing
+
+        all_ok = True
+        for jid, name, state in matched:
+            print(f"🚀 Retrying job '{name}' (ID: {jid}, status: {state})...")
+            res = subprocess.run(["bk", "job", "retry", jid])
+            if res.returncode != 0:
+                all_ok = False
+        return True, all_ok
+
+    def handle_build(
+        self,
+        target: BuildkiteBuildTarget,
+        mode: OperationMode,
+        job_pattern: str = "",
+        rebuild_if_no_failed_jobs: bool = True,
+    ) -> bool:
+        if mode == OperationMode.MATCH_JOBS:
+            matched, all_ok = self.retry_jobs_by_name(
+                target, job_pattern, fail_on_missing=True
+            )
+            return matched and all_ok
+
+        if mode == OperationMode.UNBLOCK:
+            jobs = self._view_build(target)
+            unblocked, all_ok = self.unblock_manual_jobs(jobs)
+            if not unblocked:
+                print(
+                    f"â„šī¸ No blocked manual approval jobs found in build "
+                    f"#{target.build_number}.",
+                    file=sys.stderr,
+                )
+            return all_ok
+
+        if mode == OperationMode.REBUILD:
+            return self.rebuild_build(target)
+
+        if mode == OperationMode.RETRY_FAILED:
+            try:
+                jobs = self._view_build(target)
+            except (subprocess.SubprocessError, json.JSONDecodeError):
+                if rebuild_if_no_failed_jobs:
+                    return self.rebuild_build(target)
+                return False
+
+            unblocked_jobs, unblock_ok = self.unblock_manual_jobs(jobs)
+            failed_jobs: list[dict[str, object]] = []
+            for job in jobs:
+                state = str(job.get("state") or "")
+                if (
+                    state in _FAILED_BK_JOB_STATES
+                    and job.get("id")
+                    and not job.get("retried_in_job_uuid")
+                    and not job.get("retried_at")
+                ):
+                    failed_jobs.append(job)
+
+            if failed_jobs:
+                all_ok = unblock_ok
+                for job in failed_jobs:
+                    jid = str(job["id"])
+                    jname = str(job.get("name") or job.get("label") or jid)
+                    print(f"🚀 Retrying failed job '{jname}' (ID: {jid})...")
+                    res = subprocess.run(["bk", "job", "retry", jid])
+                    if res.returncode != 0:
+                        all_ok = False
+                return all_ok
+
+            if unblocked_jobs:
+                return unblock_ok
+
+            if rebuild_if_no_failed_jobs:
+                return unblock_ok and self.rebuild_build(target)
+            return unblock_ok
+
+        raise CiRetryError(f"Unsupported operation mode: {mode}")
+
+
+class GitHubActionsProvider:
+    """Executes GitHub Actions workflow and job reruns via the `gh` CLI."""
+
+    def _ensure_cli(self) -> None:
+        _check_cli("gh", "https://cli.github.com/")
+
+    def _get_run_data(self, repo: str, run_id: str) -> dict[str, object]:
+        cmd = [
+            "gh",
+            "run",
+            "view",
+            run_id,
+            "--repo",
+            repo,
+            "--json",
+            "status,conclusion,jobs",
+        ]
+        res = subprocess.run(cmd, capture_output=True, text=True, check=True)
+        return _extract_json_object(res.stdout)
+
+    def _wait_for_run_completion(
+        self,
+        repo: str,
+        run_id: str,
+        job_id: str = "",
+    ) -> dict[str, object]:
+        for attempt in range(_GH_MAX_POLL_ATTEMPTS):
+            try:
+                data = self._get_run_data(repo, run_id)
+            except (subprocess.SubprocessError, json.JSONDecodeError) as e:
+                print(
+                    f"âš ī¸ Warning: Could not inspect GitHub Actions run {run_id}: {e}",
+                    file=sys.stderr,
+                )
+                return {}
+
+            status = str(data.get("status") or "").lower()
+            raw_jobs = data.get("jobs", [])
+            jobs = raw_jobs if isinstance(raw_jobs, list) else []
+
+            if job_id:
+                active_job_ids = {str(j.get("databaseId")) for j in jobs}
+                if job_id not in active_job_ids:
+                    # The job ID from an earlier attempt is no longer in the active
+                    # jobs list, meaning a newer run_attempt is queued/running/done.
+                    if (
+                        status != "completed"
+                        or str(data.get("conclusion") or "").lower() == "success"
+                    ):
+                        print(
+                            f"â„šī¸ GitHub Actions job {job_id} has already been "
+                            f"superseded by a newer attempt in run {run_id} "
+                            f"(status: {status})."
+                        )
+                        return data
+
+            if status == "completed":
+                return data
+
+            print(
+                f"âŗ GitHub Actions run {run_id} is currently '{status}' "
+                f"(attempt {attempt + 1}/{_GH_MAX_POLL_ATTEMPTS}); waiting "
+                f"{_GH_POLL_INTERVAL_SECONDS}s for run to complete before "
+                f"rerunning..."
+            )
+            time.sleep(_GH_POLL_INTERVAL_SECONDS)
+
+        print(
+            f"âš ī¸ Timed out waiting for GitHub Actions run {run_id} to complete.",
+            file=sys.stderr,
+        )
+        return {}
+
+    def _rerun_failed_in_run(self, repo: str, run_id: str) -> bool:
+        res = subprocess.run(["gh", "run", "rerun", run_id, "--failed", "--repo", repo])
+        return res.returncode == 0
+
+    def _rerun_job_with_fallback(
+        self, repo: str, run_id: str, job_id: str
+    ) -> tuple[bool, bool]:
+        """Reruns a single job, falling back to `--failed` on the parent run.
+
+        Returns:
+            Tuple of `(succeeded, used_run_fallback)`.
+        """
+        res = subprocess.run(["gh", "run", "rerun", "--job", job_id, "--repo", repo])
+        if res.returncode == 0:
+            return True, False
+
+        print(
+            f"âš ī¸ Single-job rerun failed for {job_id} (exit {res.returncode}); "
+            f"falling back to 'gh run rerun {run_id} --failed'..."
+        )
+        return self._rerun_failed_in_run(repo, run_id), True
+
+    def handle_run(
+        self,
+        target: GitHubRunTarget,
+        mode: OperationMode,
+        job_pattern: str = "",
+    ) -> bool:
+        self._ensure_cli()
+        if mode == OperationMode.UNBLOCK:
+            return True
+
+        data = self._wait_for_run_completion(
+            target.repo, target.run_id, job_id=target.job_id
+        )
+        raw_jobs = data.get("jobs", [])
+        jobs: list[dict[str, object]] = raw_jobs if isinstance(raw_jobs, list) else []
+
+        if mode == OperationMode.REBUILD:
+            print(
+                f"🚀 Re-running entire GitHub Actions run {target.run_id} "
+                f"({target.repo})..."
+            )
+            res = subprocess.run(
+                ["gh", "run", "rerun", target.run_id, "--repo", target.repo]
+            )
+            return res.returncode == 0
+
+        if mode == OperationMode.MATCH_JOBS:
+            matched: list[tuple[str, str, str]] = []
+            for job in jobs:
+                name = str(job.get("name") or "")
+                jid = str(job.get("databaseId") or "")
+                conclusion = str(job.get("conclusion") or job.get("status") or "")
+                if jid and _matches_job_pattern(name, job_pattern):
+                    matched.append((jid, name, conclusion))
+
+            if not matched:
+                print(
+                    f"âš ī¸ No GitHub Actions jobs found matching '{job_pattern}' "
+                    f"in run {target.run_id}.",
+                    file=sys.stderr,
+                )
+                return False
+
+            for jid, name, conclusion in matched:
+                print(
+                    f"🚀 Retrying GitHub Actions job '{name}' "
+                    f"(ID: {jid}, conclusion: {conclusion})..."
+                )
+                ok, used_fallback = self._rerun_job_with_fallback(
+                    target.repo, target.run_id, jid
+                )
+                if used_fallback or not ok:
+                    return ok
+            return True
+
+        if mode == OperationMode.RETRY_FAILED:
+            if target.job_id:
+                active_jobs_by_id = {str(j.get("databaseId")): j for j in jobs}
+                if data and target.job_id not in active_jobs_by_id:
+                    status = str(data.get("status") or "").lower()
+                    conclusion = str(data.get("conclusion") or "").lower()
+                    if status != "completed" or conclusion == "success":
+                        return True
+
+                target_job = active_jobs_by_id.get(target.job_id)
+                if (
+                    target_job
+                    and str(target_job.get("conclusion") or "").lower() == "success"
+                ):
+                    print(
+                        f"â„šī¸ GitHub Actions job '{target_job.get('name')}' "
+                        f"(ID: {target.job_id}) already succeeded."
+                    )
+                    return True
+
+                print(
+                    f"🚀 Retrying GitHub Actions job {target.job_id} in run "
+                    f"{target.run_id} ({target.repo})..."
+                )
+                ok, _ = self._rerun_job_with_fallback(
+                    target.repo, target.run_id, target.job_id
+                )
+                return ok
+
+            if data and str(data.get("conclusion") or "").lower() == "success":
+                print(f"â„šī¸ GitHub Actions run {target.run_id} already succeeded.")
+                return True
+
+            print(
+                f"🚀 Retrying failed jobs in GitHub Actions run {target.run_id} "
+                f"({target.repo})..."
+            )
+            return self._rerun_failed_in_run(target.repo, target.run_id)
+
+        raise CiRetryError(f"Unsupported operation mode: {mode}")
+
+    def retry_job_by_id(
+        self,
+        target: GitHubJobTarget,
+        mode: OperationMode = OperationMode.RETRY_FAILED,
+        job_pattern: str = "",
+    ) -> bool:
+        self._ensure_cli()
+        cmd = ["gh", "api", f"repos/{target.repo}/actions/jobs/{target.job_id}"]
+        run_id = ""
+        try:
+            res = subprocess.run(cmd, capture_output=True, text=True, check=True)
+            job_data = _extract_json_object(res.stdout)
+            run_id = str(job_data.get("run_id") or "")
+        except (subprocess.SubprocessError, json.JSONDecodeError) as e:
+            print(
+                f"âš ī¸ Could not resolve run_id for GitHub job {target.job_id} "
+                f"via API: {e}",
+                file=sys.stderr,
+            )
+
+        if run_id:
+            return self.handle_run(
+                GitHubRunTarget(
+                    repo=target.repo,
+                    run_id=run_id,
+                    job_id=target.job_id,
+                ),
+                mode=mode,
+                job_pattern=job_pattern,
+            )
+
+        print(f"🚀 Retrying GitHub Actions job ID: {target.job_id}...")
+        res = subprocess.run(
+            ["gh", "run", "rerun", "--job", target.job_id, "--repo", target.repo]
+        )
+        return res.returncode == 0
+
+
+class PrOrchestrator:
+    """Inspects PR checks and routes retries across Buildkite and GitHub Actions."""
+
+    def __init__(
+        self,
+        buildkite: BuildkiteProvider,
+        github_actions: GitHubActionsProvider,
+    ) -> None:
+        self._bk = buildkite
+        self._gh = github_actions
+
+    def _dispatch_github_runs(
+        self,
+        grouped_runs: Mapping[tuple[str, str], Sequence[str]],
+        mode: OperationMode,
+        job_pattern: str = "",
+    ) -> bool:
+        all_ok = True
+        for (repo, run_id), job_ids in grouped_runs.items():
+            single_job_id = job_ids[0] if len(job_ids) == 1 and job_ids[0] else ""
+            run_mode = mode
+            if mode == OperationMode.MATCH_JOBS and single_job_id:
+                run_mode = OperationMode.RETRY_FAILED
+            ok = self._gh.handle_run(
+                GitHubRunTarget(
+                    repo=repo,
+                    run_id=run_id,
+                    job_id=single_job_id if run_mode != OperationMode.REBUILD else "",
+                ),
+                mode=run_mode,
+                job_pattern=job_pattern if run_mode == OperationMode.MATCH_JOBS else "",
+            )
+            if not ok:
+                all_ok = False
+        return all_ok
+
+    def handle_pr(
+        self,
+        target: PrTarget,
+        mode: OperationMode,
+        job_pattern: str = "",
+    ) -> bool:
+        print(f"🔍 Inspecting PR #{target.pr_number} checks via gh...")
+        checks = _get_pr_checks(target.pr_number)
+
+        bk_checks: list[tuple[BuildkiteBuildTarget, bool]] = []
+        gh_checks: list[tuple[str, GitHubRunTarget, bool]] = []
+        for check in checks:
+            link = str(check.get("link") or "")
+            name = str(check.get("name") or "")
+            is_failed = _is_check_failed(check)
+            bk_target = _parse_buildkite_url(link)
+            if bk_target:
+                bk_checks.append((bk_target, is_failed))
+            else:
+                gh_target = _parse_github_actions_url(link)
+                if gh_target:
+                    gh_checks.append((name, gh_target, is_failed))
+
+        if mode == OperationMode.UNBLOCK:
+            if not bk_checks:
+                print(
+                    f"â„šī¸ No Buildkite checks found for PR #{target.pr_number}.",
+                    file=sys.stderr,
+                )
+                return True
+            all_ok = True
+            for bk_target, _ in bk_checks:
+                if not self._bk.handle_build(bk_target, OperationMode.UNBLOCK):
+                    all_ok = False
+            return all_ok
+
+        if mode == OperationMode.MATCH_JOBS:
+            matched_any = False
+            all_ok = True
+            matched_gh_runs: dict[tuple[str, str], list[str]] = {}
+            for cname, gh_target, _ in gh_checks:
+                if _matches_job_pattern(cname, job_pattern):
+                    matched_gh_runs.setdefault(
+                        (gh_target.repo, gh_target.run_id), []
+                    ).append(gh_target.job_id)
+
+            if matched_gh_runs:
+                matched_any = True
+                if not self._dispatch_github_runs(
+                    matched_gh_runs,
+                    mode=OperationMode.MATCH_JOBS,
+                    job_pattern=job_pattern,
+                ):
+                    all_ok = False
+
+            for bk_target, _ in bk_checks:
+                bk_matched, bk_ok = self._bk.retry_jobs_by_name(
+                    bk_target, job_pattern, fail_on_missing=False
+                )
+                if bk_matched:
+                    matched_any = True
+                if not bk_ok:
+                    all_ok = False
+
+            if not matched_any:
+                print(
+                    f"âš ī¸ No GitHub Actions or Buildkite jobs found matching "
+                    f"'{job_pattern}' for PR #{target.pr_number}.",
+                    file=sys.stderr,
+                )
+                return False
+            return all_ok
+
+        if mode in (OperationMode.RETRY_FAILED, OperationMode.REBUILD):
+            target_gh_runs: dict[tuple[str, str], list[str]] = {}
+            for _, gh_target, is_failed in gh_checks:
+                if is_failed or mode == OperationMode.REBUILD:
+                    target_gh_runs.setdefault(
+                        (gh_target.repo, gh_target.run_id), []
+                    ).append(gh_target.job_id)
+
+            all_ok = True
+            gh_retried = bool(target_gh_runs)
+            if target_gh_runs:
+                if not self._dispatch_github_runs(target_gh_runs, mode=mode):
+                    all_ok = False
+
+            for bk_target, bk_failed in bk_checks:
+                if not self._bk.handle_build(
+                    bk_target,
+                    mode=mode,
+                    rebuild_if_no_failed_jobs=(bk_failed and not gh_retried),
+                ):
+                    all_ok = False
+
+            if not bk_checks and not gh_retried:
+                print(
+                    f"â„šī¸ No failing GitHub Actions or Buildkite checks found "
+                    f"for PR #{target.pr_number}."
+                )
+            return all_ok
+
+        raise CiRetryError(f"Unsupported operation mode: {mode}")
+
+
+# ==============================================================================
+# 5. Main
+# ==============================================================================
+
+
+def main(argv: Sequence[str]) -> int:
+    args = _parse_args(argv)
+    mode = _resolve_mode(args)
+
+    try:
+        target = _resolve_target(args)
+        bk_provider = BuildkiteProvider()
+        gh_provider = GitHubActionsProvider()
+
+        if isinstance(target, BuildkiteJobTarget):
+            ok = bk_provider.retry_job_by_uuid(target)
+        elif isinstance(target, GitHubJobTarget):
+            ok = gh_provider.retry_job_by_id(
+                target, mode=mode, job_pattern=args.job_name
+            )
+        elif isinstance(target, GitHubRunTarget):
+            ok = gh_provider.handle_run(target, mode=mode, job_pattern=args.job_name)
+        elif isinstance(target, PrTarget):
+            orchestrator = PrOrchestrator(bk_provider, gh_provider)
+            ok = orchestrator.handle_pr(target, mode=mode, job_pattern=args.job_name)
+        elif isinstance(target, BuildkiteBuildTarget):
+            ok = bk_provider.handle_build(
+                target,
+                mode=mode,
+                job_pattern=args.job_name,
+                rebuild_if_no_failed_jobs=True,
+            )
+        else:
+            raise CiRetryError(f"Unsupported target type: {target}")
+    except CiRetryError as e:
+        print(str(e), file=sys.stderr)
+        return 1
+
+    return 0 if ok else 1
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))