#!/usr/bin/env python3 """Discover available Gitea Actions runners for dynamic job distribution. Queries the Gitea API for registered runners at three levels: 1. Repository level: GET /repos/{owner}/{repo}/actions/runners 2. Organization level: GET /orgs/{org}/actions/runners 3. Instance (admin) level: GET /admin/actions/runners Falls back to the ``MOLECULE_RUNNERS`` repo variable or environment variable, then to ``DEFAULT_MAX_RUNNERS`` (3). Outputs: - ``--count``: prints the number of available runners - ``--indices``: prints a JSON array [0, 1, ..., N-1] for use as a dynamic matrix in Gitea Actions - (default): prints both as ``count=N`` and ``indices=[0,1,...]`` Usage: python3 scripts/ci/discover_runners.py --owner oblachno-oss --repo grm python3 scripts/ci/discover_runners.py --indices python3 scripts/ci/discover_runners.py --count """ from __future__ import annotations import json import os import click import requests from gitea_runner_manager.config import GITEA_API_URL DEFAULT_MAX_RUNNERS = 3 def query_runners(api_url: str, token: str, owner: str, repo: str) -> int: """Query the Gitea API for registered runners at all levels. Returns the total count of active runners. If the API call fails (e.g., no admin access for instance-level runners), falls back to what we can see. """ headers = {"Authorization": f"token {token}"} total = 0 # 1. Repository-level runners try: r = requests.get( f"{api_url}/repos/{owner}/{repo}/actions/runners", headers=headers, timeout=10, ) if r.status_code == 200: data = r.json() total += data.get("total_count", 0) except (requests.RequestException, ValueError): pass # 2. Organization-level runners try: r = requests.get( f"{api_url}/orgs/{owner}/actions/runners", headers=headers, timeout=10, ) if r.status_code == 200: data = r.json() total += data.get("total_count", 0) except (requests.RequestException, ValueError): pass # 3. Instance-level runners (requires admin scope) try: r = requests.get( f"{api_url}/admin/actions/runners", headers=headers, timeout=10, ) if r.status_code == 200: data = r.json() total += data.get("total_count", 0) except (requests.RequestException, ValueError): pass return total def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int: """Determine the number of available runners. Tries the Gitea API first, then falls back to env vars, then default. """ # Try API query if we have a token if token: api_count = query_runners(api_url, token, owner, repo) if api_count > 0: return api_count # Fall back to MOLECULE_RUNNERS env var (set by CI from repo variable) env_count = os.environ.get("MOLECULE_RUNNERS") if env_count: try: count = int(env_count) if count > 0: return count except ValueError: pass # Fall back to default return DEFAULT_MAX_RUNNERS def generate_indices(count: int) -> list[str]: """Generate a list of runner indices ["1", "2", ..., "N"]. Uses 1-based string indices because Gitea Actions renders integer 0 and string "0" as empty in ${{ matrix.runner-index }} expressions, causing --runner-index to be passed without a value. The distribute_molecule.py script converts these back to 0-based internally. """ return [str(i + 1) for i in range(count)] @click.command() @click.option("--owner", default=None, help="Repository owner (for API query).") @click.option("--repo", default=None, help="Repository name (for API query).") @click.option("--count", "output_count", is_flag=True, help="Output only the count.") @click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.") @click.option( "--github-output", "github_output", is_flag=True, default=False, help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).", ) def main( owner: str | None, repo: str | None, output_count: bool, output_indices: bool, github_output: bool, ) -> None: token = os.environ.get("REPO_TOKEN", "") if owner is None: owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss") if repo is None: repo = os.environ.get("GRM_REPO_NAME", "grm") count = get_runner_count(GITEA_API_URL, token, owner, repo) indices = generate_indices(count) if github_output: gh_output = os.environ.get("GITHUB_OUTPUT") if not gh_output: raise click.ClickException("GITHUB_OUTPUT environment variable is not set") with open(gh_output, "a") as f: # noqa: PTH123 f.write(f"runner-count={count}\n") f.write(f"runner-indices={json.dumps(indices)}\n") click.echo(f"Runner count: {count}") click.echo(f"Runner indices: {indices}") return if output_count: click.echo(str(count)) return if output_indices: click.echo(json.dumps(indices)) return # Default: output both as key=value pairs for CI consumption click.echo(f"count={count}") click.echo(f"indices={json.dumps(indices)}") if __name__ == "__main__": # pragma: no cover main()