| #!/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", |
| # ] |
| # /// |
| |
| """ |
| 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. |
| 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. |
| |
| Progress and diagnostics go to stderr — a live progress bar per phase on a terminal, one |
| line per step when the output is piped — so stdout stays clean for `--json`. |
| |
| Usage: |
| uv run utils/actions-queue-status.py |
| uv run utils/actions-queue-status.py --csv /tmp/asf-ci.csv |
| uv run utils/actions-queue-status.py --repos-file utils/apache-actions-repos.txt |
| uv run utils/actions-queue-status.py --save-repos utils/apache-actions-repos.txt |
| uv run utils/actions-queue-status.py --verbose |
| """ |
| |
| import argparse |
| import collections |
| import csv |
| import json |
| import os |
| import shutil |
| import subprocess |
| import sys |
| import threading |
| import time |
| from concurrent.futures import ThreadPoolExecutor |
| from contextlib import contextmanager |
| from typing import Iterator |
| |
| import requests |
| from rich.console import Console |
| from rich.markup import escape |
| from rich.padding import Padding |
| from rich.progress import ( |
| BarColumn, |
| MofNCompleteColumn, |
| Progress, |
| SpinnerColumn, |
| TaskProgressColumn, |
| TextColumn, |
| TimeElapsedColumn, |
| TimeRemainingColumn, |
| ) |
| from rich.table import Table |
| |
| 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 |
| |
| # Above this the points balance is shown green, between this and BUDGET_FLOOR yellow, and |
| # below the floor red. Cosmetic, but an org sweep runs long enough that a glanceable |
| # signal beats reading the number. |
| POINTS_HEALTHY = 2000 |
| |
| # 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 |
| |
| REPO_PAGE_QUERY = """ |
| query($org: String!, $after: String) { |
| organization(login: $org) { |
| repositories(first: %(page)d, after: $after, orderBy: {field: PUSHED_AT, direction: DESC}) { |
| 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", |
| ] |
| |
| PMC_CSV_HEADER = [ |
| "pmc", |
| "queued_jobs", |
| "running_jobs", |
| "repos", |
| "repos_total", |
| "open_prs", |
| "runs_awaiting_approval", |
| "source", |
| "repositories", |
| ] |
| |
| |
| class Tracker: |
| """Progress handle for one phase of the sweep. |
| |
| Backed by a live `rich` bar when the reporter draws them, and by a line every |
| `log_every` steps when it does not — so a redirected run still shows movement without |
| a screenful of escape sequences. |
| """ |
| |
| def __init__( |
| self, |
| reporter: "Reporter", |
| progress: Progress | None, |
| task_id: int | None, |
| description: str, |
| total: int | None, |
| log_every: int, |
| ): |
| self.reporter = reporter |
| self.progress = progress |
| self.task_id = task_id |
| self.description = description |
| self.total = total |
| self.log_every = max(1, log_every) |
| self.completed = 0 |
| self._lock = threading.Lock() |
| |
| def advance(self, step: int = 1, detail: str = "") -> None: |
| """Move the bar on by `step`, annotating it with `detail` (points left, counts).""" |
| with self._lock: |
| self.completed += step |
| completed = self.completed |
| if self.progress is not None: |
| self.progress.update(self.task_id, advance=step, detail=detail) |
| return |
| if completed % self.log_every and completed != self.total: |
| return |
| of_total = f"/{self.total}" if self.total else "" |
| self.reporter.log(f" {self.description} {completed}{of_total} {detail}", "dim") |
| |
| |
| class Reporter: |
| """Single owner of everything the sweep prints: colour, progress bars and counters. |
| |
| Bars are drawn only when stderr is a terminal and the run is neither quiet nor |
| `--no-color`; everything else degrades to plain lines. All output goes to stderr, so |
| `--json` on stdout stays machine-readable whatever the verbosity. |
| """ |
| |
| def __init__(self, console: Console, verbose: bool = False, quiet: bool = False, bars: bool = True): |
| self.console = console |
| # --quiet wins over --verbose: asking for silence and detail at once means silence. |
| self.verbose = verbose and not quiet |
| self.quiet = quiet |
| self.use_bars = bars and console.is_terminal and not quiet |
| self.counters: collections.Counter = collections.Counter() |
| self.started = time.monotonic() |
| self._lock = threading.Lock() |
| self._points = lambda: None |
| self._phase_index = 0 |
| self._phases_planned = 0 |
| |
| def track_points(self, client: "GraphQLClient") -> None: |
| """Let the reporter read the live points balance for its progress annotations.""" |
| self._points = lambda: client.remaining |
| |
| def plan_phases(self, phases: int) -> None: |
| """Record how many phases this run intends, so banners can read `Phase 2/3`.""" |
| self._phases_planned = phases |
| |
| def count(self, name: str, amount: int = 1) -> None: |
| """Bump a run counter from any worker thread.""" |
| with self._lock: |
| self.counters[name] += amount |
| |
| def log(self, message: str, style: str = "") -> None: |
| """Print a normal diagnostic line, unless the run is quiet.""" |
| if not self.quiet: |
| self.console.print(message, style=style or None, highlight=False) |
| |
| def detail(self, message: str) -> None: |
| """Print a per-batch / per-retry / per-repo line — only under --verbose.""" |
| if self.verbose: |
| self.console.print(f" {message}", style="dim", highlight=False) |
| |
| def warn(self, message: str) -> None: |
| if not self.quiet: |
| self.console.print(f" ! {message}", style="yellow", highlight=False) |
| |
| def error(self, message: str) -> None: |
| """Print a failure. Loud enough to survive even --quiet, since the run is ending.""" |
| self.console.print(f" x {message}", style="bold red", highlight=False) |
| |
| def success(self, message: str) -> None: |
| self.log(f" {message}", "green") |
| |
| def points_markup(self) -> str: |
| """Render the points balance as a traffic light: green plenty, red nearly spent.""" |
| remaining = self._points() |
| if remaining is None: |
| return "[dim]? pts[/]" |
| if remaining > POINTS_HEALTHY: |
| style = "green" |
| elif remaining > BUDGET_FLOOR: |
| style = "yellow" |
| else: |
| style = "bold red" |
| return f"[{style}]{remaining} pts[/]" |
| |
| def _build_progress(self) -> Progress: |
| return Progress( |
| SpinnerColumn(style="cyan"), |
| TextColumn("[bold cyan]{task.description}[/]"), |
| BarColumn(complete_style="cyan", finished_style="green"), |
| MofNCompleteColumn(), |
| TaskProgressColumn(show_speed=True), |
| TextColumn("{task.fields[detail]}"), |
| TimeElapsedColumn(), |
| TextColumn("[dim]eta[/]"), |
| TimeRemainingColumn(), |
| console=self.console, |
| ) |
| |
| @contextmanager |
| def phase(self, title: str, subtitle: str = "") -> Iterator[None]: |
| """Frame one phase: a banner going in, elapsed time and points spent coming out.""" |
| self._phase_index += 1 |
| label = f"Phase {self._phase_index}/{self._phases_planned}" if self._phases_planned else "Phase" |
| started = time.monotonic() |
| before = self._points() |
| if not self.quiet: |
| banner = f"\n[bold cyan]▸ {label} {title}[/]" |
| self.console.print(banner + (f" [dim]{subtitle}[/]" if subtitle else ""), highlight=False) |
| yield |
| after = self._points() |
| spent = f", {before - after} points" if before is not None and after is not None else "" |
| self.success(f"{title} finished in {time.monotonic() - started:,.1f}s{spent}") |
| |
| @contextmanager |
| def tracker(self, description: str, total: int | None, log_every: int = 1) -> Iterator[Tracker]: |
| """Yield a progress handle for one phase — a live bar, or periodic lines.""" |
| if not self.use_bars: |
| yield Tracker(self, None, None, description, total, log_every) |
| return |
| progress = self._build_progress() |
| with progress: |
| task_id = progress.add_task(description, total=total, detail="") |
| yield Tracker(self, progress, task_id, description, total, log_every) |
| |
| def summary(self, client: "GraphQLClient") -> None: |
| """Print the end-of-run accounting: work done, what went wrong, points spent.""" |
| if self.quiet: |
| return |
| counters = self.counters |
| rows = [ |
| ("GraphQL queries", counters["graphql_queries"], "cyan"), |
| ("GraphQL retries", counters["graphql_retries"], "yellow"), |
| ("GraphQL give-ups", counters["graphql_failures"], "red"), |
| ("Batches split", counters["batch_splits"], "yellow"), |
| ("Repos skipped", counters["repos_skipped"], "yellow"), |
| ("REST requests", counters["rest_requests"], "cyan"), |
| ("REST retries", counters["rest_retries"], "yellow"), |
| ("REST give-ups", counters["rest_failures"], "red"), |
| ("Repos re-counted over REST", counters["repos_recounted"], "cyan"), |
| ] |
| table = Table( |
| title="Run diagnostics", |
| title_justify="left", |
| title_style="bold cyan", |
| box=None, |
| show_header=False, |
| pad_edge=False, |
| ) |
| table.add_column(style="dim") |
| table.add_column(justify="right") |
| for label, value, style in rows: |
| # A zero for a failure counter is the good outcome, so leave it dim rather |
| # than painting it with the warning colour the non-zero case earns. |
| table.add_row(label, f"[{style if value else 'dim'}]{value}[/]") |
| spent = client.points_spent |
| table.add_row("GraphQL points spent", f"[cyan]{spent}[/]" if spent is not None else "[dim]?[/]") |
| table.add_row("GraphQL points left", self.points_markup()) |
| table.add_row("Wall time", f"[cyan]{time.monotonic() - self.started:,.1f}s[/]") |
| self.console.print(table) |
| |
| |
| 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, |
| reporter: Reporter | None = None, |
| ): |
| self.token = token |
| self.use_requests = use_requests |
| self.reporter = reporter |
| self._lock = threading.Lock() |
| self._remaining: int | None = None |
| self._first_remaining: int | None = None |
| if use_requests and not token: |
| raise SystemExit("--no-gh requires --github-token, GH_TOKEN or GITHUB_TOKEN") |
| 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 |
| |
| @property |
| def points_spent(self) -> int | None: |
| """Points burned since the first query that reported a balance. None if unknown.""" |
| with self._lock: |
| if self._first_remaining is None or self._remaining is None: |
| return None |
| return self._first_remaining - 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"] |
| if self._first_remaining is None: |
| self._first_remaining = limit["remaining"] |
| |
| def _count(self, name: str, amount: int = 1) -> None: |
| if self.reporter: |
| self.reporter.count(name, amount) |
| |
| def _detail(self, message: str) -> None: |
| if self.reporter: |
| self.reporter.detail(message) |
| |
| 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): |
| self._count("graphql_queries") |
| 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): |
| self._detail(f"[yellow]query failed, not retryable:[/] {escape(error)}") |
| break |
| if attempt == attempts - 1: |
| # Nothing follows the last attempt, so sleeping its backoff would only |
| # delay the give-up by minutes. |
| 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. |
| delay = 4 ** (attempt + 1) |
| self._count("graphql_retries") |
| self._detail( |
| f"[yellow]query attempt {attempt + 1}/{attempts} failed, retrying in " |
| f"{delay}s:[/] {escape(error)}" |
| ) |
| time.sleep(delay) |
| self._count("graphql_failures") |
| 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): |
| self._count("rest_requests") |
| 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: |
| self._count("rest_failures") |
| self._detail(f"[yellow]REST {escape(path)} returned unparseable JSON[/]") |
| return None |
| error = (result.stderr or "")[:150] |
| if not any(marker in error for marker in RETRYABLE): |
| self._count("rest_failures") |
| self._detail(f"[yellow]REST {escape(path)} failed:[/] {escape(error)}") |
| return None |
| if attempt == attempts - 1: |
| break # As above: no attempt follows, so the backoff would buy nothing. |
| delay = 5 * (attempt + 1) |
| self._count("rest_retries") |
| self._detail( |
| f"[yellow]REST {escape(path)} attempt {attempt + 1}/{attempts} failed, " |
| f"retrying in {delay}s:[/] {escape(error)}" |
| ) |
| time.sleep(delay) |
| self._count("rest_failures") |
| 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 resolve_token(args: argparse.Namespace) -> str | None: |
| """Resolve the token: --github-token, then GH_TOKEN, then GITHUB_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 None |
| |
| |
| 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(reporter: Reporter, 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 or reporter.quiet: |
| return |
| total = len(open_pr_counts) |
| reporter.log(" Open-PR distribution (repos GraphQL could cover outright):", "cyan") |
| table = Table(box=None, pad_edge=False) |
| table.add_column("--prs", style="dim", justify="right") |
| table.add_column("covered", justify="right") |
| table.add_column("share", justify="right") |
| for threshold in (1, 3, 5, 10, 25, 50): |
| covered = sum(1 for count in open_pr_counts if count <= threshold) |
| share = covered / total |
| # Green once a setting covers most of the org, so the knee of the curve stands out. |
| style = "green" if share >= 0.9 else "yellow" if share >= 0.5 else "dim" |
| table.add_row(str(threshold), f"{covered} / {total}", f"[{style}]{share:.0%}[/]") |
| reporter.console.print(Padding(table, (0, 0, 0, 2), expand=False)) |
| |
| |
| def discover_repos(client: GraphQLClient, reporter: Reporter, 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 |
| skipped = 0 |
| cursor = None |
| page_number = 0 |
| with reporter.tracker("Discovery", total=None) as tracker: |
| 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. |
| reporter.error(f"discovery failed after {scanned} repos (cursor {cursor})") |
| raise SystemExit( |
| f"discovery failed after {scanned} repos (cursor {cursor}): {payload['__error__']}" |
| ) |
| page = payload["data"]["organization"]["repositories"] |
| page_number += 1 |
| for node in page["nodes"]: |
| scanned += 1 |
| if node["isDisabled"] or (node["isArchived"] and not include_archived): |
| skipped += 1 |
| continue |
| if not node.get("defaultBranchRef"): |
| skipped += 1 |
| continue |
| if has_workflows(node): |
| repos.append(node["name"]) |
| open_pr_counts.append((node.get("pullRequests") or {}).get("totalCount", 0)) |
| tracker.advance( |
| len(page["nodes"]), |
| detail=f"[cyan]{len(repos)}[/] with workflows · {reporter.points_markup()}", |
| ) |
| reporter.detail(f"page {page_number}: {len(page['nodes'])} repos, cursor {cursor or 'start'}") |
| if not page["pageInfo"]["hasNextPage"]: |
| break |
| cursor = page["pageInfo"]["endCursor"] |
| reporter.log( |
| f" {scanned} repos scanned, [bold]{len(repos)}[/] use GitHub Actions " |
| f"([dim]{skipped} archived, disabled or empty[/])", |
| "cyan", |
| ) |
| report_pr_distribution(reporter, 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, reporter: Reporter, 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"): |
| error = escape(str(payload.get("__error__"))) |
| if len(names) == 1: |
| reporter.count("repos_skipped") |
| reporter.warn(f"skipped [bold]{names[0]}[/]: {error}") |
| return [] |
| middle = len(names) // 2 |
| reporter.count("batch_splits") |
| reporter.detail(f"[yellow]batch of {len(names)} failed, splitting in two:[/] {error}") |
| return run_batch(client, reporter, org, names[:middle], prs, suites) + run_batch( |
| client, reporter, 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, reporter: Reporter, 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: |
| reporter.warn(f"REST re-count of [bold]{escape(name)}[/] failed — keeping the GraphQL sample") |
| 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 |
| reporter.count("repos_recounted") |
| reporter.detail( |
| f"{escape(name)}: {len(active)} active runs → [cyan]{running}[/] running, " |
| f"[cyan]{queued}[/] queued" |
| + (f", [yellow]{awaiting_approval}[/] awaiting approval" if awaiting_approval else "") |
| + f" (GraphQL sampled {row['running_jobs']} running, {row['queued_jobs']} queued)" |
| ) |
| return { |
| **row, |
| "queued_jobs": queued, |
| "running_jobs": running, |
| "runs_awaiting_approval": awaiting_approval, |
| "source": "rest", |
| "workflows": workflows, |
| } |
| |
| |
| def pmc_of(repo: str) -> str: |
| """Return the PMC a repository name belongs to: the text before the first hyphen. |
| |
| The same rule actions-audit.py's --pmc filter uses, so the two scripts agree on what |
| `spark` covers. It is a naming convention rather than authoritative ownership -- an |
| `incubator-` repo groups under `incubator`, not under the podling's eventual PMC -- |
| but every ASF repo is named this way, and it needs no network call to apply. |
| """ |
| return repo.split("/")[-1].split("-", 1)[0] |
| |
| |
| def group_by_pmc(rows: list[dict], population: list[str] | None = None) -> list[dict]: |
| """Aggregate per-repo summaries into one row per PMC. |
| |
| `population` is every repo the sweep covered, active or not. It is what makes the |
| active count mean something: four busy repos is a different picture for a PMC of |
| four than for a PMC of forty-seven. Without it a PMC's total is just its active |
| repos, which is what a caller with no wider list can honestly say. |
| """ |
| totals: dict[str, int] = collections.Counter(pmc_of(name) for name in population or []) |
| groups: dict[str, dict] = {} |
| for row in rows: |
| name = row["repo"].split("/")[-1] |
| group = groups.setdefault( |
| pmc_of(name), |
| { |
| "pmc": pmc_of(name), |
| "queued_jobs": 0, |
| "running_jobs": 0, |
| "open_prs": 0, |
| "runs_awaiting_approval": 0, |
| "repos": [], |
| "sources": set(), |
| }, |
| ) |
| group["queued_jobs"] += row["queued_jobs"] |
| group["running_jobs"] += row["running_jobs"] |
| group["open_prs"] += row.get("open_prs", 0) or 0 |
| group["runs_awaiting_approval"] += row.get("runs_awaiting_approval", 0) or 0 |
| group["repos"].append(name) |
| group["sources"].add(row.get("source", "graphql")) |
| for group in groups.values(): |
| group["repos"].sort() |
| # A repo with jobs is by definition part of its PMC's estate, so the count can |
| # never be smaller than what was found active -- even if the population somehow |
| # did not list it. |
| group["repos_total"] = max(totals.get(group["pmc"], 0), len(group["repos"])) |
| # A PMC counted partly each way is neither: say so rather than pick a winner. |
| group["source"] = group["sources"].pop() if len(group["sources"]) == 1 else "mixed" |
| del group["sources"] |
| return list(groups.values()) |
| |
| |
| 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, |
| ] |
| |
| |
| def pmc_csv_row(row: dict) -> list: |
| """Flatten one PMC summary into CSV cells.""" |
| return [ |
| row["pmc"], |
| row["queued_jobs"], |
| row["running_jobs"], |
| len(row["repos"]), |
| row["repos_total"], |
| row["open_prs"], |
| row["runs_awaiting_approval"], |
| row["source"], |
| "; ".join(row["repos"]), |
| ] |
| |
| |
| 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 write_repos_file(path: str, 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 --save-repos <path>\n" |
| f"# Pass it back with --repos-file to skip discovery. Refresh it periodically --\n" |
| f"# a stale list silently omits repos that have since adopted Actions.\n" |
| f"#\n" |
| ) |
| handle.write("\n".join(sorted(repos)) + "\n") |
| |
| |
| def write_csv(reporter: Reporter, path: str, rows: list[dict], totals: dict, by_pmc: bool = False) -> None: |
| """Write one ordering of the snapshot as CSV.""" |
| with open(path, "w", newline="") as handle: |
| writer = csv.writer(handle) |
| writer.writerow(PMC_CSV_HEADER if by_pmc else CSV_HEADER) |
| for row in rows: |
| writer.writerow(pmc_csv_row(row) if by_pmc else csv_row(row)) |
| total_row = ["TOTAL", totals["queued_jobs"], totals["running_jobs"]] |
| if by_pmc: |
| total_row += [ |
| totals["repos_active"], |
| totals["repos_with_actions"], |
| "", |
| "", |
| "", |
| f"pmcs={totals['pmcs_active']}", |
| ] |
| else: |
| total_row += ["", "", "", f"repos={totals['repos_active']}"] |
| writer.writerow(total_row) |
| reporter.success(f"Wrote {path} ({len(rows)} rows)") |
| |
| |
| def render_table( |
| reporter: Reporter, title: str, rows: list[dict], top: int, totals: dict, by_pmc: bool = False |
| ) -> None: |
| """Print one ordering of the snapshot as a rich table.""" |
| table = Table(title=title, title_justify="left", title_style="bold") |
| table.add_column("PMC" if by_pmc else "Repository", style="bold") |
| table.add_column("Queued", justify="right", style="yellow") |
| table.add_column("Running", justify="right", style="green") |
| if by_pmc: |
| # Which API counted a row is a per-repo fact that a PMC of several repos can only |
| # blur, so the grouped table spends the column on the repo count instead. The CSV |
| # still carries it, as "mixed" where the repos disagree. |
| table.add_column("Repos", justify="right") |
| table.add_column("Repositories", style="dim") |
| else: |
| # The source column says how the row was counted: an exact REST re-count reads |
| # differently from a GraphQL sample, so it is worth colouring the two apart. |
| table.add_column("Source") |
| table.add_column("Workflows", style="dim") |
| for row in rows[:top]: |
| if by_pmc: |
| table.add_row( |
| row["pmc"], |
| str(row["queued_jobs"]), |
| str(row["running_jobs"]), |
| f"{len(row['repos'])} / {row['repos_total']}", |
| escape(", ".join(row["repos"])[:50]), |
| ) |
| continue |
| workflows = ", ".join(sorted(row["workflows"])) or "-" |
| source = row.get("source", "graphql") |
| table.add_row( |
| row["repo"], |
| str(row["queued_jobs"]), |
| str(row["running_jobs"]), |
| f"[cyan]{source}[/]" if source == "rest" else f"[dim]{source}[/]", |
| escape(workflows[:50]), |
| ) |
| # The org-wide total is printed once above the tables, which has scrolled away by the |
| # time a long table has been read — so each table closes with it. When the table is |
| # truncated the visible rows are subtotalled too, since what --top leaves out is the |
| # question the total otherwise raises. |
| table.add_section() |
| if len(rows) > top: |
| table.add_row( |
| f"[dim]shown (top {top})[/]", |
| f"[dim]{sum(row['queued_jobs'] for row in rows[:top])}[/]", |
| f"[dim]{sum(row['running_jobs'] for row in rows[:top])}[/]", |
| "", |
| "", |
| ) |
| active = totals["pmcs_active"] if by_pmc else totals["repos_active"] |
| unit = "PMC" if by_pmc else "repo" |
| table.add_row( |
| "[bold]TOTAL[/]", |
| f"[bold]{totals['queued_jobs']}[/]", |
| f"[bold]{totals['running_jobs']}[/]", |
| f"[bold]{totals['repos_active']} / {totals['repos_with_actions']}[/]" if by_pmc else "", |
| f"[dim]{active} {unit}{'' if active == 1 else 's'}[/]", |
| ) |
| if len(rows) > top: |
| table.caption = ( |
| f"showing top {top} of {len(rows)} active {unit}s — raise --top for more" |
| ) |
| table.caption_justify = "left" |
| reporter.console.print(table) |
| |
| |
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser( |
| 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( |
| "--by-pmc", |
| action="store_true", |
| help="group rows by PMC: the repo name's prefix before the first hyphen", |
| ) |
| parser.add_argument("--include-archived", action="store_true", help="include archived repos") |
| parser.add_argument("--repos-file", help="skip discovery; newline-separated repo names") |
| parser.add_argument("--save-repos", help="write the discovered repo list here") |
| 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)") |
| 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", |
| ) |
| parser.add_argument( |
| "-v", |
| "--verbose", |
| action="store_true", |
| help="add per-batch, per-retry and per-repo diagnostics to the progress output", |
| ) |
| parser.add_argument( |
| "-q", |
| "--quiet", |
| action="store_true", |
| help="suppress progress and diagnostics; print only the result (overrides --verbose)", |
| ) |
| parser.add_argument( |
| "--no-color", |
| action="store_true", |
| help="disable colour and progress bars (NO_COLOR in the environment does the same)", |
| ) |
| return parser.parse_args() |
| |
| |
| def main() -> int: |
| args = parse_args() |
| # rich drops colour on NO_COLOR by itself, but not the bars — and a bar redrawing in |
| # monochrome is exactly what someone setting NO_COLOR is trying to avoid. |
| plain = args.no_color or bool(os.environ.get("NO_COLOR")) |
| console = Console(stderr=True, no_color=plain) |
| reporter = Reporter(console, verbose=args.verbose, quiet=args.quiet, bars=not plain) |
| # Discovery is skipped when a repo list is supplied, and the REST pass only runs when |
| # the GraphQL sample turned out partial — so the phase count reflects this run's plan. |
| reporter.plan_phases((0 if args.repos_file else 1) + 1 + (0 if args.no_rest_fallback else 1)) |
| client = GraphQLClient(token=resolve_token(args), use_requests=args.no_gh, reporter=reporter) |
| reporter.track_points(client) |
| reporter.log( |
| f"Sweeping [bold]{args.org}[/] via {'requests' if args.no_gh else 'the gh CLI'} " |
| f"([dim]--prs {args.prs} --suites {args.suites} --batch-size {args.batch_size} " |
| f"--workers {args.workers}[/])", |
| "cyan", |
| ) |
| |
| if args.repos_file: |
| with open(args.repos_file) as handle: |
| repos = [ |
| line.strip() for line in handle if line.strip() and not line.lstrip().startswith("#") |
| ] |
| reporter.log(f"Loaded [bold]{len(repos)}[/] repos from {escape(args.repos_file)}", "cyan") |
| else: |
| with reporter.phase("Discovery", f"org {args.org}, reading .github/workflows per repo"): |
| repos = discover_repos(client, reporter, args.org, args.include_archived) |
| if args.save_repos: |
| write_repos_file(args.save_repos, args.org, repos) |
| reporter.success(f"Wrote {args.save_repos} ({len(repos)} repos)") |
| |
| batches = [repos[index : index + args.batch_size] for index in range(0, len(repos), args.batch_size)] |
| |
| results: list[dict] = [] |
| truncated = False |
| with reporter.phase("Status", f"{len(batches)} queries of up to {args.batch_size} repos"): |
| with reporter.tracker("Status", total=len(batches), log_every=10) as tracker: |
| with ThreadPoolExecutor(max_workers=args.workers) as pool: |
| futures = [ |
| pool.submit(run_batch, client, reporter, 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() |
| reporter.warn(f"{exhausted} — reporting {done - 1} completed batches") |
| break |
| tracker.advance(detail=reporter.points_markup()) |
| |
| # 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: |
| subtitle = ( |
| f"{len(incomplete)} repos with more than {args.prs} open PRs or more than " |
| f"{args.suites} check suites on a commit" |
| ) |
| with reporter.phase("REST re-count", subtitle): |
| exact_by_repo = {} |
| with reporter.tracker("REST", total=len(incomplete), log_every=10) as tracker: |
| |
| def recount(item: dict) -> dict: |
| row = recount_over_rest(client, reporter, args.org, item) |
| tracker.advance(detail=f"[bold]{escape(row['repo'])}[/]") |
| return row |
| |
| with ThreadPoolExecutor(max_workers=args.workers) as pool: |
| for row in pool.map(recount, incomplete): |
| exact_by_repo[row["repo"]] = row |
| results = [exact_by_repo.get(row["repo"], row) for row in results] |
| elif incomplete: |
| reporter.warn( |
| f"{len(incomplete)} repos are only sampled (--no-rest-fallback): counts are a lower bound" |
| ) |
| elif not args.no_rest_fallback: |
| # Says why the planned REST phase never appeared: the sample covered everything. |
| reporter.log(" No REST re-count needed — the GraphQL sample covered every repo", "green") |
| |
| active = [row for row in results if row["queued_jobs"] or row["running_jobs"]] |
| grouped = group_by_pmc(active, repos) |
| if args.by_pmc: |
| key = "pmc" |
| rows = grouped |
| else: |
| key = "repo" |
| rows = active |
| by_running = sorted(rows, key=lambda row: (-row["running_jobs"], -row["queued_jobs"], row[key])) |
| by_queued = sorted(rows, key=lambda row: (-row["queued_jobs"], -row["running_jobs"], row[key])) |
| totals = { |
| "org": args.org, |
| "repos_with_actions": len(results), |
| "repos_active": len(active), |
| "pmcs_active": len(grouped), |
| "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: |
| reporter.summary(client) |
| print(json.dumps({"totals": totals, "by_running": by_running, "by_queued": by_queued}, indent=2)) |
| return 0 |
| |
| reporter.summary(client) |
| console.print( |
| f"\n[bold]{args.org}[/]: [green]{totals['running_jobs']}[/] jobs running, " |
| f"[yellow]{totals['queued_jobs']}[/] queued across {totals['repos_active']} of " |
| f"{totals['repos_with_actions']} repos with Actions" |
| + (f" in {totals['pmcs_active']} PMCs" if args.by_pmc else ""), |
| highlight=False, |
| ) |
| if truncated: |
| console.print("[bold yellow]Counts are partial: the run stopped on the points budget.[/]") |
| render_table(reporter, "Sorted by RUNNING jobs", by_running, args.top, totals, args.by_pmc) |
| render_table(reporter, "Sorted by QUEUED jobs", by_queued, args.top, totals, args.by_pmc) |
| |
| if args.csv: |
| write_csv(reporter, csv_path(args.csv, "by-running"), by_running, totals, args.by_pmc) |
| write_csv(reporter, csv_path(args.csv, "by-queued"), by_queued, totals, args.by_pmc) |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |