| #!/usr/bin/env python3 |
| # |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| # |
| |
| # /// script |
| # requires-python = ">=3.11" |
| # dependencies = [ |
| # "requests>=2.31", |
| # "rich>=13.0", |
| # "rich-argparse>=1.6", |
| # ] |
| # /// |
| |
| """ |
| Report which ASF repositories currently have GitHub Actions jobs queued or running. |
| |
| Answers "who is using the runners right now, and who is waiting" without org-admin |
| rights: the self-hosted runner endpoints need `admin:org`, but check-run state is |
| readable by anyone who can read the repo. |
| |
| Two phases, both GraphQL: |
| |
| 1. Discovery — page through the org's repositories and read the `.github/workflows` |
| tree directly, so a repo counts as using Actions only when it really has workflow |
| files on its default branch. It is skipped when the org's repo list is already |
| stored beside this script; --delete-cached-projects re-discovers and rewrites it. |
| 2. Status — batch the surviving repos into aliased queries (one request covers many |
| repos) and count check runs, which map one-to-one onto workflow jobs. A repo with |
| more open PRs than the sample size cannot be covered that way, so those — and only |
| those — are re-counted exactly over REST. |
| |
| Usage: |
| uv run utils/actions-queue-status.py |
| uv run utils/actions-queue-status.py --delete-cached-projects |
| uv run utils/actions-queue-status.py --csv /tmp/asf-ci.csv |
| uv run utils/actions-queue-status.py --repos-file /tmp/repos.txt --top 40 |
| """ |
| |
| import argparse |
| import csv |
| import json |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import threading |
| import time |
| from concurrent.futures import ThreadPoolExecutor |
| from datetime import date, datetime, timezone |
| from pathlib import Path |
| |
| import requests |
| from rich.console import Console |
| from rich.progress import ( |
| BarColumn, |
| MofNCompleteColumn, |
| Progress, |
| SpinnerColumn, |
| TextColumn, |
| TimeElapsedColumn, |
| ) |
| from rich.table import Column, Table |
| from rich_argparse import RichHelpFormatter |
| |
| console = Console(stderr=True) |
| |
| GITHUB_GRAPHQL_URL = "https://api.github.com/graphql" |
| GITHUB_REST_URL = "https://api.github.com" |
| |
| # Run states that still hold — or are still waiting for — a runner. "waiting" and |
| # "action_required" are approval gates rather than capacity waits, and are counted |
| # separately so the two are not confused. |
| ACTIVE_RUN_STATES = {"queued", "in_progress", "waiting", "pending", "requested", "action_required"} |
| APPROVAL_RUN_STATES = {"waiting", "action_required", "requested", "pending"} |
| |
| # GitHub answers a query it considers too expensive with a bare 502 rather than a typed |
| # error, and enforces the points budget per minute as well as per hour — so a burst |
| # trips it even when the hourly balance looks healthy. Both are worth waiting out. |
| RETRYABLE = ("502", "503", "504", "rate limit", "RATE_LIMIT", "secondary rate", "timeout") |
| |
| # Points held back so a sweep never leaves the caller's hourly budget at zero. |
| BUDGET_FLOOR = 200 |
| |
| # Repos per discovery page. Each node costs a tree lookup and an open-PR count, and at |
| # 100 the query times out server-side often enough to end a sweep: two consecutive full |
| # runs died on HTTP 502 — after 200 and 300 repos — with all four retries exhausted. |
| # The same paging at 50 walked the whole org without a single retry. |
| DISCOVERY_PAGE_SIZE = 50 |
| |
| # How old a stored list may get before the load says so. A stale list fails silently — |
| # the sweep reports totals across the repos it was handed, with nothing to show which |
| # ones it never looked at — so the age is worth a line of its own. |
| STALE_AFTER_DAYS = 30 |
| |
| REPO_PAGE_QUERY = """ |
| query($org: String!, $after: String) { |
| organization(login: $org) { |
| repositories(first: %(page)d, after: $after, orderBy: {field: PUSHED_AT, direction: DESC}) { |
| totalCount |
| pageInfo { hasNextPage endCursor } |
| nodes { |
| name |
| isArchived |
| isDisabled |
| defaultBranchRef { name } |
| pullRequests(states: OPEN) { totalCount } |
| workflows: object(expression: "HEAD:.github/workflows") { |
| ... on Tree { entries { name } } |
| } |
| } |
| } |
| } |
| rateLimit { cost remaining resetAt } |
| } |
| """ % {"page": DISCOVERY_PAGE_SIZE} |
| |
| STATUS_FRAGMENTS = """ |
| fragment CI on Repository { |
| nameWithOwner |
| defaultBranchRef { target { ... on Commit { ...Suites } } } |
| pullRequests(states: OPEN, first: %(prs)d, orderBy: {field: UPDATED_AT, direction: DESC}) { |
| totalCount |
| nodes { commits(last: 1) { nodes { commit { ...Suites } } } } |
| } |
| } |
| fragment Suites on Commit { |
| checkSuites(first: %(suites)d) { |
| totalCount |
| nodes { |
| status |
| workflowRun { workflow { name } } |
| queued: checkRuns(first: 1, filterBy: {status: QUEUED}) { totalCount } |
| running: checkRuns(first: 1, filterBy: {status: IN_PROGRESS}) { totalCount } |
| } |
| } |
| } |
| """ |
| |
| CSV_HEADER = [ |
| "repo", |
| "queued_jobs", |
| "running_jobs", |
| "open_prs", |
| "runs_awaiting_approval", |
| "source", |
| "workflows", |
| ] |
| |
| |
| class BudgetExhausted(RuntimeError): |
| """Raised when the GraphQL points budget runs too low to keep querying safely.""" |
| |
| |
| class GraphQLClient: |
| """Minimal GraphQL client over the `gh` CLI, or `requests` when `--no-gh` is given. |
| |
| Tracks the points balance reported by each query. The in-query `rateLimit` block is |
| the only trustworthy source: the REST `/rate_limit` endpoint keeps reporting a full |
| GraphQL budget while the API is actively rejecting queries as rate limited. |
| """ |
| |
| def __init__(self, token: str | None = None, use_requests: bool = False): |
| self.token = token |
| self.use_requests = use_requests |
| self._lock = threading.Lock() |
| self._remaining: int | None = None |
| if use_requests and not token: |
| raise SystemExit( |
| "--no-gh requires --github-token, GH_TOKEN, GITHUB_TOKEN or an authenticated gh CLI" |
| ) |
| if not use_requests and not shutil.which("gh"): |
| raise SystemExit("gh CLI not found — install it, or use --no-gh with a token") |
| |
| @property |
| def remaining(self) -> int | None: |
| """Return the points balance reported by the most recent successful query.""" |
| with self._lock: |
| return self._remaining |
| |
| def _note_budget(self, payload: dict) -> None: |
| limit = (payload.get("data") or {}).get("rateLimit") |
| if limit: |
| with self._lock: |
| self._remaining = limit["remaining"] |
| |
| def _call_gh(self, query: str, variables: dict) -> tuple[dict | None, str]: |
| cmd = ["gh", "api", "graphql", "-f", f"query={query}"] |
| for key, value in variables.items(): |
| cmd.extend(["-f", f"{key}={value}"]) |
| result = subprocess.run(cmd, capture_output=True, text=True, check=False) |
| if result.returncode != 0: |
| return None, (result.stderr or result.stdout).strip()[:200] |
| try: |
| return json.loads(result.stdout), "" |
| except json.JSONDecodeError: |
| return None, result.stdout.strip()[:200] |
| |
| def _call_requests(self, query: str, variables: dict) -> tuple[dict | None, str]: |
| response = requests.post( |
| GITHUB_GRAPHQL_URL, |
| headers={"Authorization": f"bearer {self.token}", "Accept": "application/json"}, |
| json={"query": query, "variables": variables}, |
| timeout=60, |
| ) |
| if response.status_code != 200: |
| return None, f"HTTP {response.status_code}: {response.text[:150]}" |
| return response.json(), "" |
| |
| def query(self, query: str, variables: dict | None = None, attempts: int = 4) -> dict: |
| """Run a query, retrying transient failures, and return the payload. |
| |
| On give-up the returned dict carries `__error__` instead of raising: callers |
| decide whether a failed repo is fatal or merely skipped. |
| """ |
| variables = variables or {} |
| error = "no attempt made" |
| for attempt in range(attempts): |
| payload, error = ( |
| self._call_requests(query, variables) |
| if self.use_requests |
| else self._call_gh(query, variables) |
| ) |
| if payload is not None and not payload.get("errors"): |
| self._note_budget(payload) |
| return payload |
| if payload is not None and payload.get("errors"): |
| error = json.dumps(payload["errors"])[:200] |
| if not any(marker in error for marker in RETRYABLE): |
| break |
| # 4s, 16s, 64s. The per-minute points cap needs real time to drain; a tight |
| # retry only burns more of the budget it is waiting on. |
| time.sleep(4 ** (attempt + 1)) |
| return {"__error__": error} |
| |
| def rest(self, path: str, attempts: int = 3) -> dict | None: |
| """GET a REST endpoint, retrying transient failures. None when it cannot be read.""" |
| for attempt in range(attempts): |
| if self.use_requests: |
| response = requests.get( |
| f"{GITHUB_REST_URL}/{path}", |
| headers={"Authorization": f"bearer {self.token}", "Accept": "application/json"}, |
| timeout=60, |
| ) |
| if response.status_code == 200: |
| return response.json() |
| error = f"HTTP {response.status_code}" |
| else: |
| result = subprocess.run( |
| ["gh", "api", "--method", "GET", path], capture_output=True, text=True, check=False |
| ) |
| if result.returncode == 0: |
| try: |
| return json.loads(result.stdout) |
| except json.JSONDecodeError: |
| return None |
| error = (result.stderr or "")[:150] |
| if not any(marker in error for marker in RETRYABLE): |
| return None |
| time.sleep(5 * (attempt + 1)) |
| return None |
| |
| def check_budget(self) -> None: |
| """Stop the sweep before it drains the caller's hourly GraphQL allowance.""" |
| remaining = self.remaining |
| if remaining is not None and remaining < BUDGET_FLOOR: |
| raise BudgetExhausted(f"stopping with {remaining} GraphQL points left") |
| |
| |
| def gh_auth_token() -> str | None: |
| """Return the token the `gh` CLI is logged in with, or None if it cannot supply one. |
| |
| Lets the script work out of the box for anyone already running `gh auth login`, without |
| minting a second PAT just to set GH_TOKEN. |
| """ |
| gh = shutil.which("gh") |
| if not gh: |
| return None |
| try: |
| result = subprocess.run([gh, "auth", "token"], capture_output=True, text=True, check=True) |
| except (subprocess.CalledProcessError, OSError): |
| return None |
| return result.stdout.strip() or None |
| |
| |
| def resolve_token(args: argparse.Namespace) -> str | None: |
| """Resolve the token: --github-token, then GH_TOKEN / GITHUB_TOKEN, then `gh auth token`.""" |
| if args.github_token: |
| return args.github_token |
| for name in ("GH_TOKEN", "GITHUB_TOKEN"): |
| value = os.environ.get(name) |
| if value: |
| return value |
| return gh_auth_token() |
| |
| |
| def has_workflows(node: dict) -> bool: |
| """Report whether the repo's default branch carries at least one workflow file.""" |
| tree = node.get("workflows") |
| if not tree: |
| return False |
| return any(entry["name"].endswith((".yml", ".yaml")) for entry in tree.get("entries", [])) |
| |
| |
| def report_pr_distribution(open_pr_counts: list[int]) -> None: |
| """Show how many repos each candidate --prs value would cover outright. |
| |
| The knee of this curve is what --prs should be set to: below it, GraphQL cannot cover |
| the repo and REST re-counts it anyway; above it, the sweep pays node cost for PRs that |
| almost no repository has. |
| """ |
| if not open_pr_counts: |
| return |
| total = len(open_pr_counts) |
| console.print("[cyan]Open-PR distribution (repos GraphQL could cover outright):[/]") |
| for threshold in (1, 3, 5, 10, 25, 50): |
| covered = sum(1 for count in open_pr_counts if count <= threshold) |
| console.print(f"[dim] --prs {threshold:>3}: {covered:>5} / {total} repos ({covered / total:.0%})[/]") |
| |
| |
| def progress_bar() -> Progress: |
| """Build the progress display used for the long paging loops. |
| |
| Rendered on stderr like every other status message, so `--json` on stdout stays a |
| clean document, and transient so the bar leaves no residue behind the summary line. |
| """ |
| return Progress( |
| SpinnerColumn(), |
| TextColumn("[cyan]{task.description}"), |
| BarColumn(bar_width=20), |
| MofNCompleteColumn(), |
| TimeElapsedColumn(), |
| TextColumn( |
| "[dim]{task.fields[note]}[/]", |
| # Last, and capped to whatever width is left over, so a cramped terminal |
| # ellipsizes the running commentary instead of squeezing the counts. |
| table_column=Column(no_wrap=True, overflow="ellipsis", max_width=max(10, console.width - 56)), |
| ), |
| console=console, |
| transient=True, |
| ) |
| |
| |
| def discover_repos(client: GraphQLClient, org: str, include_archived: bool) -> list[str]: |
| """Return every non-archived repo in the org that defines workflow files.""" |
| repos: list[str] = [] |
| open_pr_counts: list[int] = [] |
| scanned = 0 |
| cursor = None |
| with progress_bar() as progress: |
| # The org's repo count only arrives with the first page, so the bar starts out |
| # indeterminate and gets its total on the first update. |
| task = progress.add_task("Discovering repos", total=None, note="") |
| while True: |
| variables = {"org": org} |
| if cursor: |
| variables["after"] = cursor |
| payload = client.query(REPO_PAGE_QUERY, variables) |
| if "__error__" in payload: |
| # Partial discovery silently under-reports the org, which is worse than no |
| # answer at all — fail loudly and say how far the paging got. |
| raise SystemExit( |
| f"discovery failed after {scanned} repos (cursor {cursor}): {payload['__error__']}" |
| ) |
| page = payload["data"]["organization"]["repositories"] |
| for node in page["nodes"]: |
| scanned += 1 |
| if node["isDisabled"] or (node["isArchived"] and not include_archived): |
| continue |
| if not node.get("defaultBranchRef"): |
| continue |
| if has_workflows(node): |
| repos.append(node["name"]) |
| open_pr_counts.append((node.get("pullRequests") or {}).get("totalCount", 0)) |
| progress.update( |
| task, |
| completed=scanned, |
| total=page["totalCount"], |
| note=f"{len(repos)} with workflows, points left {client.remaining}", |
| ) |
| if not page["pageInfo"]["hasNextPage"]: |
| break |
| cursor = page["pageInfo"]["endCursor"] |
| console.print(f"[cyan]Discovery: {scanned} repos scanned, {len(repos)} use GitHub Actions[/]") |
| report_pr_distribution(open_pr_counts) |
| return repos |
| |
| |
| def build_status_query(org: str, names: list[str], prs: int, suites: int) -> str: |
| """Build one aliased query covering every repo in the batch.""" |
| aliases = "\n".join( |
| f' r{index}: repository(owner: "{org}", name: "{name}") {{ ...CI }}' |
| for index, name in enumerate(names) |
| ) |
| budget = " rateLimit { cost remaining resetAt }" |
| fragments = STATUS_FRAGMENTS % {"prs": prs, "suites": suites} |
| return f"query {{\n{aliases}\n{budget}\n}}\n{fragments}" |
| |
| |
| def collect_commits(repo_node: dict) -> list[dict]: |
| """Flatten a repo's default-branch head and open-PR heads into a commit list.""" |
| commits = [] |
| head = (repo_node.get("defaultBranchRef") or {}).get("target") |
| if head: |
| commits.append(head) |
| for pull_request in (repo_node.get("pullRequests") or {}).get("nodes", []): |
| for entry in (pull_request.get("commits") or {}).get("nodes", []): |
| if entry.get("commit"): |
| commits.append(entry["commit"]) |
| return commits |
| |
| |
| def summarize_repo(repo_node: dict) -> dict: |
| """Reduce one repo's check suites to queued/running job counts.""" |
| queued = running = suites_queued = suites_running = 0 |
| max_suites_on_a_commit = 0 |
| workflows: dict[str, int] = {} |
| for commit in collect_commits(repo_node): |
| suite_page = commit.get("checkSuites") or {} |
| max_suites_on_a_commit = max(max_suites_on_a_commit, suite_page.get("totalCount", 0)) |
| for suite in suite_page.get("nodes", []): |
| suite_queued = suite["queued"]["totalCount"] |
| suite_running = suite["running"]["totalCount"] |
| queued += suite_queued |
| running += suite_running |
| if suite["status"] == "QUEUED": |
| suites_queued += 1 |
| elif suite["status"] == "IN_PROGRESS": |
| suites_running += 1 |
| run = suite.get("workflowRun") |
| if run and (suite_queued + suite_running): |
| name = run["workflow"]["name"] |
| workflows[name] = workflows.get(name, 0) + suite_queued + suite_running |
| return { |
| "repo": repo_node["nameWithOwner"], |
| "queued_jobs": queued, |
| "running_jobs": running, |
| "suites_queued": suites_queued, |
| "suites_in_progress": suites_running, |
| "open_prs": (repo_node.get("pullRequests") or {}).get("totalCount", 0), |
| "max_suites_on_a_commit": max_suites_on_a_commit, |
| "source": "graphql", |
| "workflows": workflows, |
| } |
| |
| |
| def run_batch(client: GraphQLClient, org: str, names: list[str], prs: int, suites: int) -> list[dict]: |
| """Query one batch, halving it on failure so one heavy repo cannot sink the rest. |
| |
| Halving is why the budget has to be checked here and not only by the caller: a batch |
| that splits all the way down issues far more queries than the plan accounted for. |
| """ |
| client.check_budget() |
| payload = client.query(build_status_query(org, names, prs, suites)) |
| if "__error__" in payload or not payload.get("data"): |
| if len(names) == 1: |
| console.print(f"[yellow] skipped {names[0]}: {payload.get('__error__')}[/]") |
| return [] |
| middle = len(names) // 2 |
| return run_batch(client, org, names[:middle], prs, suites) + run_batch( |
| client, org, names[middle:], prs, suites |
| ) |
| return [ |
| summarize_repo(node) for key, node in payload["data"].items() if node and key != "rateLimit" |
| ] |
| |
| |
| def recount_over_rest(client: GraphQLClient, org: str, row: dict) -> dict: |
| """Re-count one repo exactly over REST, for repos the PR window cannot cover. |
| |
| GraphQL caps `pullRequests(first:)` at 100 and the sweep samples far fewer, so a repo |
| with more open PRs than the sample size is necessarily under-counted. REST has no |
| org-wide equivalent, but per repo it is exact: list the runs that are still active and |
| count their jobs. |
| """ |
| name = row["repo"].split("/", 1)[1] |
| runs = client.rest(f"repos/{org}/{name}/actions/runs?per_page=100") |
| if not runs: |
| return row # Keep the GraphQL sample rather than reporting a repo as idle. |
| active = [run for run in runs.get("workflow_runs", []) if run.get("status") in ACTIVE_RUN_STATES] |
| queued = running = awaiting_approval = 0 |
| workflows: dict[str, int] = {} |
| for run in active: |
| if run["status"] in APPROVAL_RUN_STATES: |
| awaiting_approval += 1 |
| jobs = client.rest(f"repos/{org}/{name}/actions/runs/{run['id']}/jobs?per_page=100&filter=latest") |
| if not jobs: |
| continue |
| for job in jobs.get("jobs", []): |
| if job["status"] not in {"queued", "in_progress"}: |
| continue |
| if job["status"] == "in_progress": |
| running += 1 |
| else: |
| queued += 1 |
| name_of_run = run.get("name") or "?" |
| workflows[name_of_run] = workflows.get(name_of_run, 0) + 1 |
| return { |
| **row, |
| "queued_jobs": queued, |
| "running_jobs": running, |
| "runs_awaiting_approval": awaiting_approval, |
| "source": "rest", |
| "workflows": workflows, |
| } |
| |
| |
| def csv_path(base: str, ordering: str) -> str: |
| """Derive the per-ordering CSV filename from the --csv argument.""" |
| stem, dot, extension = base.rpartition(".") |
| return f"{stem}-{ordering}{dot}{extension}" if dot else f"{base}-{ordering}" |
| |
| |
| def csv_row(row: dict) -> list: |
| """Flatten one repo summary into CSV cells.""" |
| workflows = "; ".join(f"{name}={count}" for name, count in sorted(row["workflows"].items())) |
| return [ |
| row["repo"], |
| row["queued_jobs"], |
| row["running_jobs"], |
| row.get("open_prs", 0), |
| row.get("runs_awaiting_approval", ""), |
| row.get("source", "graphql"), |
| workflows, |
| ] |
| |
| |
| ASF_HEADER = """\ |
| # |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| # |
| """ |
| |
| |
| def default_repos_file(org: str) -> Path: |
| """Path of the repo list stored beside this script for an organisation. |
| |
| Naming the file after the org is what keeps the default honest: a sweep of another |
| org finds no file of its own and discovers, rather than answering from apache's. |
| """ |
| return Path(__file__).with_name(f"{org}-actions-repos.txt") |
| |
| |
| def display_path(path: str | Path) -> str: |
| """Render a path the way the caller would type it: relative to the working directory.""" |
| try: |
| return str(Path(path).relative_to(Path.cwd())) |
| except ValueError: |
| return str(path) |
| |
| |
| def read_repos_file(path: str | Path) -> list[str]: |
| """Read a repo list, ignoring the '#' header that lets the file be committed.""" |
| with open(path) as handle: |
| return [ |
| line.strip() for line in handle if line.strip() and not line.lstrip().startswith("#") |
| ] |
| |
| |
| def write_repos_file(path: str | Path, org: str, repos: list[str]) -> None: |
| """Write a discovered repo list, with the header that lets it be committed. |
| |
| The list is worth keeping under version control — discovery is the slowest and |
| most rate-limit-hungry phase of a sweep — so the file carries the ASF header RAT |
| expects and records when it was generated, since it goes stale as repos appear, |
| are archived, or adopt Actions. |
| |
| Names are sorted rather than left in discovery's push order, which reshuffles on |
| every run: a refresh should diff as the repos that joined and left, nothing else. |
| """ |
| with open(path, "w") as handle: |
| handle.write(ASF_HEADER) |
| handle.write( |
| f"#\n" |
| f"# Repositories in the {org} organisation that define GitHub Actions workflows.\n" |
| f"# {len(repos)} repos, discovered {time.strftime('%Y-%m-%d', time.gmtime())} (UTC).\n" |
| f"#\n" |
| f"# Generated by: uv run utils/actions-queue-status.py --delete-cached-projects\n" |
| f"# Read by default, and by --repos-file. Refresh it periodically — a stale list\n" |
| f"# silently omits repos that have since adopted Actions.\n" |
| f"#\n" |
| ) |
| handle.write("\n".join(sorted(repos)) + "\n") |
| |
| |
| def repos_file_age_days(path: Path) -> int | None: |
| """Days since the list was discovered, per its header, or None if it records no date.""" |
| with open(path) as handle: |
| for line in handle: |
| if not line.lstrip().startswith("#"): |
| return None |
| found = re.search(r"discovered (\d{4}-\d{2}-\d{2})", line) |
| if found: |
| return (datetime.now(timezone.utc).date() - date.fromisoformat(found.group(1))).days |
| return None |
| |
| |
| def stored_repos_file(args: argparse.Namespace) -> Path | None: |
| """Return the stored repo list this run should read, or None to discover afresh.""" |
| path = default_repos_file(args.org) |
| if not path.exists(): |
| return None |
| if args.delete_cached_projects: |
| console.print(f"[yellow]Rediscovering {args.org} and rewriting {path.name}[/]") |
| return None |
| if args.include_archived: |
| # The stored list was discovered without archived repos, so it cannot answer |
| # --include-archived — discovering is the only way to honour the flag. |
| console.print(f"[yellow]{path.name} holds no archived repos — discovering afresh[/]") |
| return None |
| return path |
| |
| |
| def write_csv(path: str, rows: list[dict], totals: dict) -> None: |
| """Write one ordering of the snapshot as CSV.""" |
| with open(path, "w", newline="") as handle: |
| writer = csv.writer(handle) |
| writer.writerow(CSV_HEADER) |
| for row in rows: |
| writer.writerow(csv_row(row)) |
| writer.writerow( |
| [ |
| "TOTAL", |
| totals["queued_jobs"], |
| totals["running_jobs"], |
| "", |
| "", |
| "", |
| f"repos={totals['repos_active']}", |
| ] |
| ) |
| console.print(f"[green]Wrote {path}[/]") |
| |
| |
| def render_table(title: str, rows: list[dict], top: int) -> None: |
| """Print one ordering of the snapshot as a rich table.""" |
| table = Table(title=title, title_justify="left") |
| table.add_column("Repository") |
| table.add_column("Queued", justify="right") |
| table.add_column("Running", justify="right") |
| table.add_column("Source") |
| table.add_column("Workflows") |
| for row in rows[:top]: |
| workflows = ", ".join(sorted(row["workflows"])) or "-" |
| table.add_row( |
| row["repo"], |
| str(row["queued_jobs"]), |
| str(row["running_jobs"]), |
| row.get("source", "graphql"), |
| workflows[:50], |
| ) |
| console.print(table) |
| |
| |
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser( |
| formatter_class=RichHelpFormatter, |
| description="Report repos with GitHub Actions jobs queued or running right now.", |
| epilog=( |
| "Repos the GraphQL sample cannot cover — more open PRs than --prs, or more " |
| "check suites on a commit than --suites — are re-counted exactly over REST, so " |
| "the totals are not a sample. Remaining caveat: neither API attributes a job to a " |
| "runner, so a job held by a concurrency group cannot be told apart from one " |
| "waiting for capacity — runs blocked on approval are reported separately in " |
| "runs_awaiting_approval. Use the REST runner endpoints (admin:org) for true " |
| "runner state." |
| ), |
| ) |
| parser.add_argument("--org", default="apache", help="organisation to sweep (default: apache)") |
| parser.add_argument("--batch-size", type=int, default=20, help="repos per GraphQL query") |
| # GraphQL's node cost climbs sharply with this: measured over the ~1250 apache repos |
| # with workflows, a sweep costs ~250 points at 3, ~2700 at 5 and ~21000 at 10, against |
| # a 5000/hour budget. Raising it only buys coverage of quiet repos, and those cost one |
| # cheap REST call each — so the low value is both cheaper and no less accurate. |
| parser.add_argument("--prs", type=int, default=3, help="open PRs sampled per repo") |
| parser.add_argument("--suites", type=int, default=5, help="check suites read per commit") |
| # Three is about the most this survives: GitHub enforces a per-minute points cap as |
| # well as the hourly one, and six workers tripped it partway through an org sweep. |
| parser.add_argument("--workers", type=int, default=3, help="batched queries in flight") |
| parser.add_argument("--top", type=int, default=25, help="rows shown per table") |
| parser.add_argument("--include-archived", action="store_true", help="include archived repos") |
| parser.add_argument( |
| "--repos-file", |
| help="skip discovery; newline-separated repo names, '#' lines ignored", |
| ) |
| parser.add_argument("--save-repos", help="write the discovered repo list here") |
| parser.add_argument( |
| "--delete-cached-projects", |
| action="store_true", |
| help="ignore the stored repo list, discover afresh, and rewrite it", |
| ) |
| parser.add_argument("--csv", metavar="PATH", help="write both orderings as CSV next to PATH") |
| parser.add_argument("--json", action="store_true", help="print JSON instead of tables") |
| parser.add_argument( |
| "--github-token", |
| help="GitHub token (default: GH_TOKEN / GITHUB_TOKEN, then `gh auth token`)", |
| ) |
| parser.add_argument("--no-gh", action="store_true", help="use requests instead of the gh CLI") |
| parser.add_argument( |
| "--no-rest-fallback", |
| action="store_true", |
| help="skip the exact REST re-count for repos the GraphQL sample could not cover", |
| ) |
| args = parser.parse_args() |
| # Both of these would let --delete-cached-projects claim a refresh it did not do, or |
| # do one that leaves the stored list describing something other than what it says. |
| if args.delete_cached_projects and args.repos_file: |
| parser.error("--delete-cached-projects contradicts --repos-file: nothing would be rewritten") |
| if args.delete_cached_projects and args.include_archived: |
| parser.error( |
| "--delete-cached-projects contradicts --include-archived: the stored list holds " |
| "no archived repos, and the default sweep reads it as if none exist" |
| ) |
| return args |
| |
| |
| def main() -> int: |
| args = parse_args() |
| client = GraphQLClient(token=resolve_token(args), use_requests=args.no_gh) |
| |
| stored = None if args.repos_file else stored_repos_file(args) |
| if args.repos_file or stored: |
| source = args.repos_file or stored |
| repos = read_repos_file(source) |
| console.print(f"[cyan]Loaded {len(repos)} repos from {display_path(source)}[/]") |
| age = repos_file_age_days(Path(source)) |
| if age is not None and age > STALE_AFTER_DAYS: |
| console.print( |
| f"[yellow]That list was discovered {age} days ago — repos that have adopted " |
| f"Actions since are missing from this sweep. Refresh it with " |
| f"--delete-cached-projects.[/]" |
| ) |
| elif age is not None: |
| console.print(f"[dim]Discovered {age} days ago — --delete-cached-projects refreshes it.[/]") |
| else: |
| repos = discover_repos(client, args.org, args.include_archived) |
| if args.delete_cached_projects: |
| # Rewritten only once discovery has succeeded: a sweep that dies partway |
| # through should cost the caller time, not the list they already had. |
| stored_path = default_repos_file(args.org) |
| write_repos_file(stored_path, args.org, repos) |
| console.print(f"[green]Wrote {display_path(stored_path)} ({len(repos)} repos)[/]") |
| if args.save_repos: |
| write_repos_file(args.save_repos, args.org, repos) |
| console.print(f"[green]Wrote {args.save_repos} ({len(repos)} repos)[/]") |
| |
| batches = [repos[index : index + args.batch_size] for index in range(0, len(repos), args.batch_size)] |
| console.print(f"[cyan]Status: {len(batches)} queries of up to {args.batch_size} repos[/]") |
| |
| results: list[dict] = [] |
| truncated = False |
| sweep = progress_bar() |
| with ThreadPoolExecutor(max_workers=args.workers) as pool, sweep as progress: |
| task = progress.add_task("Reading repo status", total=len(batches), note="") |
| futures = [ |
| pool.submit(run_batch, client, args.org, batch, args.prs, args.suites) for batch in batches |
| ] |
| for done, future in enumerate(futures, start=1): |
| try: |
| results.extend(future.result()) |
| except BudgetExhausted as exhausted: |
| truncated = True |
| for pending in futures: |
| pending.cancel() |
| console.print(f"[yellow]{exhausted} — reporting {done - 1} completed batches[/]") |
| break |
| progress.update( |
| task, |
| completed=done, |
| note=f"{len(results)} repos, points left {client.remaining}", |
| ) |
| |
| # The GraphQL pass covered a repo exhaustively only if *both* of its sampling limits |
| # held: no more open PRs than --prs, and no commit carrying more check suites than |
| # --suites. Checking only the first is not enough — a repo with a single open PR can |
| # still have a dozen suites on its head commit, and reading five of them under-counts |
| # it badly. Everything else is re-counted over REST, which is exact per repo. |
| incomplete = [ |
| row |
| for row in results |
| if row.get("open_prs", 0) > args.prs or row.get("max_suites_on_a_commit", 0) > args.suites |
| ] |
| if incomplete and not args.no_rest_fallback: |
| console.print( |
| f"[cyan]Re-counting {len(incomplete)} repos over REST (more than {args.prs} open PRs " |
| f"or more than {args.suites} check suites on a commit, so the GraphQL sample is " |
| f"partial)[/]" |
| ) |
| exact_by_repo = {} |
| recount = progress_bar() |
| with ThreadPoolExecutor(max_workers=args.workers) as pool, recount as progress: |
| task = progress.add_task("Re-counting over REST", total=len(incomplete), note="") |
| for row in pool.map(lambda item: recount_over_rest(client, args.org, item), incomplete): |
| exact_by_repo[row["repo"]] = row |
| progress.update(task, completed=len(exact_by_repo), note=row["repo"].split("/", 1)[1]) |
| results = [exact_by_repo.get(row["repo"], row) for row in results] |
| |
| active = [row for row in results if row["queued_jobs"] or row["running_jobs"]] |
| by_running = sorted(active, key=lambda row: (-row["running_jobs"], -row["queued_jobs"], row["repo"])) |
| by_queued = sorted(active, key=lambda row: (-row["queued_jobs"], -row["running_jobs"], row["repo"])) |
| totals = { |
| "org": args.org, |
| "repos_with_actions": len(results), |
| "repos_active": len(active), |
| "queued_jobs": sum(row["queued_jobs"] for row in results), |
| "running_jobs": sum(row["running_jobs"] for row in results), |
| "truncated": truncated, |
| } |
| |
| if args.json: |
| print(json.dumps({"totals": totals, "by_running": by_running, "by_queued": by_queued}, indent=2)) |
| return 0 |
| |
| console.print( |
| f"\n[bold]{args.org}[/]: {totals['running_jobs']} jobs running, " |
| f"{totals['queued_jobs']} queued across {totals['repos_active']} of " |
| f"{totals['repos_with_actions']} repos with Actions" |
| ) |
| if truncated: |
| console.print("[yellow]Counts are partial: the run stopped on the points budget.[/]") |
| render_table("Sorted by RUNNING jobs", by_running, args.top) |
| render_table("Sorted by QUEUED jobs", by_queued, args.top) |
| |
| if args.csv: |
| write_csv(csv_path(args.csv, "by-running"), by_running, totals) |
| write_csv(csv_path(args.csv, "by-queued"), by_queued, totals) |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |