"""Pure Gitea-Actions "recent executions" operations shared by the CLI and web UI. Feeds the dashboard's Executions card: the most recent workflow runs (the live ones included) with the build VM(s) each one spawned attached to its row. Zero ``click``, zero ``print`` — every call returns an :class:`ExecutionsView`. Data source is the Gitea Actions API (1.24+): * ``GET /api/v1/admin/actions/runs`` — every run across every repo in one call; needs an admin-scoped token. * ``GET /api/v1/user/actions/runs`` — the fallback when the token is not admin: the runs of the repos that user can see. Per-run job/step detail (``/runs/{run}/jobs``) is deliberately not fetched: one list call per refresh, no fan-out. **Never raises.** Missing token, unreachable host, HTTP error, malformed payload — each degrades to a view carrying ``error`` plus the live VMs, so a Gitea outage can never take the dashboard down with it. Results (errors included) are cached for :data:`_TTL_SEC`, so the card's 5s htmx poll costs at most one API call per TTL window; VM correlation is redone on every call, so the VM columns stay live even on a cache hit. """ from __future__ import annotations import json import logging import time import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass, field, replace from datetime import UTC, datetime from typing import TYPE_CHECKING, Any from ci_orchestrator.config import gitea_base_url from ci_orchestrator.operations.vms import parse_clone_name if TYPE_CHECKING: # pragma: no cover from ci_orchestrator.config import Config from ci_orchestrator.operations.vms import LiveVm logger = logging.getLogger(__name__) # Short: the dashboard polls every 5s and a slow Gitea must not hold a # request open long enough to pile polls up on top of each other. _TIMEOUT_SEC = 6.0 # MUST be set explicitly. The Gitea instance this talks to sits behind # Cloudflare, which answers urllib's default ``Python-urllib/3.x`` agent with a # 403 ("error code: 1010") before the request ever reaches Gitea -- an opaque # failure that looks exactly like a token problem. Any ordinary agent string # passes. _USER_AGENT = "ci-orchestrator (+local CI dashboard)" # One API call per window at most, however many pollers ask. _TTL_SEC = 10.0 _RUNNING = "running" _QUEUED = "queued" _UNKNOWN = "unknown" # Shown when Gitea accepts the token but refuses both listing endpoints: the # token carries no API read scope. Names both fixes, since which one applies # depends on whether the operator can add scopes to the OAuth2 app. _SCOPE_HINT = ( "Gitea accepted the token but refused it (HTTP 403): it carries no API " "read scope. Add read:repository (or read:admin) to [web.oidc] scopes and " "log in again, or configure a keyring PAT via [web] gitea_token_cred." ) # Gitea's ``status`` field, for runs that have not reached a conclusion yet. _STATUS_MAP = { "in_progress": _RUNNING, "running": _RUNNING, "waiting": _QUEUED, "queued": _QUEUED, "blocked": _QUEUED, } # Its ``conclusion`` field once they have. _CONCLUSION_MAP = { "success": "success", "failure": "failure", "cancelled": "cancelled", "skipped": "skipped", } @dataclass(frozen=True) class Execution: """One workflow run, with the live VM(s) it spawned (if any).""" run_id: int number: int attempt: int workflow: str repo: str branch: str commit: str event: str actor: str status: str started: datetime | None completed: datetime | None duration_sec: int | None html_url: str vms: list[LiveVm] = field(default_factory=list) @dataclass(frozen=True) class ExecutionsView: """Everything the Executions card renders, degraded states included. ``unmatched_vms`` holds the running VMs that belong to no listed run — a run older than the window, or a genuine orphan. They are rendered as trailing rows so the dashboard never loses sight of a powered-on VM (nor of its Remove action) just because Gitea is unreachable. ``auth_failed`` distinguishes "the token was refused" (``401``) from any other failure, so the caller can try refreshing it once before giving up. ``refused`` is the wider "this token will not work" signal (``401`` *or* a final ``403``, i.e. insufficient scope): the web layer uses it to fall back from the operator's login token to the configured PAT, which is the only way a PAT can help while a scope-less login token is in play. """ runs: list[Execution] unmatched_vms: list[LiveVm] source: str error: str | None auth_failed: bool = False refused: bool = False def _monotonic() -> float: """Indirection so tests can drive the TTL cache without sleeping.""" return time.monotonic() # key -> (fetched_at, runs, source, error, auth_failed, refused) _CacheEntry = tuple[float, list[dict[str, Any]], str, str | None, bool, bool] _CACHE: dict[tuple[str, str, int], _CacheEntry] = {} def clear_cache() -> None: """Drop the TTL cache (used by tests and after a token change).""" _CACHE.clear() def _get(payload: dict[str, Any], key: str, default: str = "") -> str: value = payload.get(key) return value if isinstance(value, str) else default def _int(payload: dict[str, Any], key: str, default: int = 0) -> int: value = payload.get(key) return value if isinstance(value, int) and not isinstance(value, bool) else default def _login(payload: dict[str, Any], *keys: str) -> str: """First ``{key: {"login": ...}}`` that actually carries a login.""" for key in keys: holder = payload.get(key) if isinstance(holder, dict): login = holder.get("login") if isinstance(login, str) and login: return login return "" def _timestamp(payload: dict[str, Any], key: str) -> datetime | None: """Parse an API timestamp; Gitea's zero value (``0001-01-01``) is ``None``.""" raw = payload.get(key) if not isinstance(raw, str) or not raw: return None try: parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: return None if parsed.year <= 1: return None if parsed.tzinfo is None: return parsed.replace(tzinfo=UTC) return parsed def _workflow_name(path: str) -> str: """Workflow file name out of Gitea's ``path``. Gitea sends either a plain path (``.gitea/workflows/lint.yml``) or the ``@`` form (``lint.yml@refs/heads/main``). Splitting on ``/`` alone would turn the latter into ``"main"``, so the ref is dropped first. """ return path.split("@", 1)[0].rsplit("/", 1)[-1] def _map_status(status: str, conclusion: str) -> str: """Collapse Gitea's ``status`` + ``conclusion`` pair into one card state.""" mapped = _STATUS_MAP.get(status) if mapped is not None: return mapped return _CONCLUSION_MAP.get(conclusion, _UNKNOWN) def _duration( status: str, started: datetime | None, completed: datetime | None, now: datetime ) -> int | None: if started is None: return None end = completed if completed is not None else (now if status == _RUNNING else None) if end is None: return None return max(int((end - started).total_seconds()), 0) def _to_execution(payload: dict[str, Any], now: datetime) -> Execution: """Map one ``ActionWorkflowRun``. Every field degrades on its own.""" status = _map_status(_get(payload, "status"), _get(payload, "conclusion")) started = _timestamp(payload, "started_at") completed = _timestamp(payload, "completed_at") repository = payload.get("repository") repo = ( _get(repository, "full_name") if isinstance(repository, dict) else "" ) return Execution( run_id=_int(payload, "id"), number=_int(payload, "run_number"), # Gitea reports ``run_attempt = 0`` for a first (never-retried) run, # while the job id the workflow builds uses github.run_attempt, which # starts at 1 -- so 0 must read as 1 or no VM would ever match. attempt=_int(payload, "run_attempt", 1) or 1, workflow=_workflow_name(_get(payload, "path")), repo=repo, branch=_get(payload, "head_branch"), commit=_get(payload, "head_sha"), event=_get(payload, "event"), actor=_login(payload, "trigger_actor", "actor"), status=status, started=started, completed=completed, duration_sec=_duration(status, started, completed, now), html_url=_get(payload, "html_url"), ) def _fetch(url: str, token: str) -> list[dict[str, Any]]: """GET one runs endpoint. Raises on transport/HTTP/payload trouble.""" # URL is built from the configured Gitea base URL, never user input. request = urllib.request.Request( url, headers={ "Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": _USER_AGENT, }, ) with urllib.request.urlopen(request, timeout=_TIMEOUT_SEC) as response: body = json.loads(response.read().decode("utf-8")) runs = body.get("workflow_runs") if isinstance(body, dict) else None if not isinstance(runs, list): raise ValueError("unexpected payload: no 'workflow_runs' array") return [item for item in runs if isinstance(item, dict)] def _http_detail(exc: urllib.error.HTTPError, *, limit: int = 160) -> str: """Whatever the server said about the refusal, trimmed for a card notice. Worth surfacing: Gitea's own body names the missing scope (``required=[read:user], token scope=...``), and a Cloudflare interception in front of it says something else entirely. Guessing from the status code alone sent this exact investigation down the wrong path once already. Unreadable bodies degrade to ``""`` -- this must never mask the real error. """ try: raw = exc.read().decode("utf-8", errors="replace").strip() except Exception: return "" if not raw: return "" try: parsed = json.loads(raw) except json.JSONDecodeError: detail = " ".join(raw.split()) else: message = parsed.get("message") if isinstance(parsed, dict) else None detail = str(message) if message else " ".join(raw.split()) return detail[:limit] def _fetch_runs( base_url: str, token: str, limit: int ) -> tuple[list[dict[str, Any]], str, str | None, bool, bool]: """Admin endpoint first, user endpoint as the non-admin fallback. Returns ``(runs, source, error, auth_failed, refused)``; never raises. """ query = urllib.parse.urlencode({"limit": limit}) admin_url = f"{base_url}/api/v1/admin/actions/runs?{query}" user_url = f"{base_url}/api/v1/user/actions/runs?{query}" last_error: str | None = None auth_failed = False refused = False for source, url in (("admin", admin_url), ("user", user_url)): try: return _fetch(url, token), source, None, False, False except urllib.error.HTTPError as exc: last_error = f"Gitea API returned HTTP {exc.code}. {_http_detail(exc)}".strip() if exc.code == 401: auth_failed = True refused = True break if exc.code in (403, 404) and source == "admin": # Token is not admin-scoped: try the per-user listing. continue if exc.code == 403: # Both endpoints refused an otherwise-valid token: Gitea # authenticated it and then rejected its scope. Say so -- # "HTTP 403" alone sends the operator hunting the wrong thing. last_error = _SCOPE_HINT refused = True break except (urllib.error.URLError, TimeoutError, OSError) as exc: last_error = f"Gitea unreachable: {exc}." break except (json.JSONDecodeError, ValueError) as exc: last_error = f"Unreadable Gitea response: {exc}" break logger.warning("executions fetch failed: %s", last_error) return [], "none", last_error, auth_failed, refused def _cached_runs( base_url: str, token: str, limit: int ) -> tuple[list[dict[str, Any]], str, str | None, bool, bool]: """:func:`_fetch_runs` behind the TTL cache (errors are cached too).""" key = (base_url, token, limit) now = _monotonic() entry = _CACHE.get(key) if entry is not None and now - entry[0] < _TTL_SEC: _fetched_at, runs, source, error, auth_failed, refused = entry return runs, source, error, auth_failed, refused runs, source, error, auth_failed, refused = _fetch_runs(base_url, token, limit) _CACHE[key] = (now, runs, source, error, auth_failed, refused) return runs, source, error, auth_failed, refused def _recency_key(run: Execution) -> tuple[datetime, int]: """Sort key for "newest first" (used with ``sorted(..., reverse=True)``). A run that has not started yet (queued) is the newest thing there is, so it sorts above every started run. ``run_id`` breaks ties — Gitea hands ids out in creation order — which keeps the order stable when several runs share a timestamp, as matrix jobs routinely do. """ return (run.started or datetime.max.replace(tzinfo=UTC), run.run_id) def _attach_vms( runs: list[Execution], vms: list[LiveVm] ) -> tuple[list[Execution], list[LiveVm]]: """Group live VMs under the run that spawned them. The job id passed to ``job`` is ``{run_id}-{run_attempt}[-suffix]`` (see ``.gitea/actions/local-ci-build/action.yml``) and the clone is named ``Clone___``, so the run's ``-`` is a prefix of the parsed job id. A matrix run therefore collects several VMs; anything left over is returned separately. """ by_prefix: dict[str, list[LiveVm]] = {} unmatched: list[LiveVm] = [] prefixes = {f"{run.run_id}-{run.attempt}" for run in runs} for vm in vms: job_id = parse_clone_name(vm.name).job_id match = None if job_id is not None: match = next( ( prefix for prefix in prefixes if job_id == prefix or job_id.startswith(f"{prefix}-") ), None, ) if match is None: unmatched.append(vm) else: by_prefix.setdefault(match, []).append(vm) attached = [ replace(run, vms=by_prefix.get(f"{run.run_id}-{run.attempt}", [])) for run in runs ] return attached, unmatched def list_executions( config: Config, vms: list[LiveVm], *, token: str | None, limit: int | None = None, now: datetime | None = None, ) -> ExecutionsView: """Recent Gitea Actions runs, with ``vms`` grouped under their own run. ``token`` is a Gitea API bearer token — the web UI passes the access token from the operator's OIDC login (kept server-side), falling back to an optional keyring PAT. ``None`` (or no configured Gitea URL) is not an error state to shout about: the view simply degrades to listing the live VMs, exactly what the card it replaces showed. """ effective_limit = config.web.executions_limit if limit is None else limit moment = now if now is not None else datetime.now(UTC) base_url = gitea_base_url(config.web) if base_url is None: return ExecutionsView( runs=[], unmatched_vms=list(vms), source="none", error="Gitea URL not configured ([web] gitea_url or [web.oidc] issuer).", ) if not token: return ExecutionsView( runs=[], unmatched_vms=list(vms), source="none", error="Gitea token not configured.", ) raw_runs, source, error, auth_failed, refused = _cached_runs( base_url.rstrip("/"), token, effective_limit ) if error is not None: return ExecutionsView( runs=[], unmatched_vms=list(vms), source=source, error=error, auth_failed=auth_failed, refused=refused, ) runs, unmatched = _attach_vms( sorted( (_to_execution(item, moment) for item in raw_runs), key=_recency_key, reverse=True, ), vms, ) return ExecutionsView( runs=runs, unmatched_vms=unmatched, source=source, error=None ) __all__ = [ "Execution", "ExecutionsView", "clear_cache", "list_executions", ]