"""GitHub Actions workflow monitoring via the gh CLI.""" import json import subprocess import time from dataclasses import dataclass from pathlib import Path from typing import Any from pydantic import BaseModel from pydantic_ai.toolsets import FunctionToolset from capabilities.base import CapabilityWithTools, DownloadedFile from config import REPO # Terminal conclusion values returned by the gh CLI. TERMINAL_STATUSES: frozenset[str] = frozenset( {"success", "failure", "cancelled", "timed_out", "action_required", "skipped"} ) class WorkflowRunResult(BaseModel): """Return value of wait_for_workflow_run.""" run_id: int conclusion: str url: str class WorkflowRunSummary(BaseModel): """A single entry returned by list_workflow_runs.""" run_id: int name: str # displayTitle — human-readable run name workflow: str # workflowName — workflow file name branch: str status: str # queued | in_progress | completed conclusion: str # success | failure | cancelled | "" when not yet completed createdAt: str url: str class WorkflowJobSummary(BaseModel): """A single job entry returned by list_workflow_run_jobs.""" job_id: int name: str status: str # queued | in_progress | completed conclusion: ( str # success | failure | cancelled | skipped | "" when not yet completed ) url: str # ── Helpers ────────────────────────────────────────────────────────────────────── def _run(*args: str) -> str: result = subprocess.run(["gh", *args], capture_output=True, text=True, check=True) return result.stdout # ── Raw helpers (internal use only) ───────────────────────────────────────────── def get_run_status(repo: str, run_id: int) -> str: """Return "running" or the gh conclusion string once the run completes.""" out = _run( "run", "view", str(run_id), "--repo", repo, "--json", "status,conclusion" ) data: dict[str, Any] = json.loads(out) if data.get("status") == "completed": return str(data.get("conclusion") or "unknown") return "running" def download_run_logs(repo: str, run_id: int, job_id: int = 0) -> Path: """Download workflow run (or job) logs to a temp file and return the path. When job_id is given, fetches logs for that specific job only. Tries --log-failed first; falls back to --log when no failed steps are present (i.e. when --log-failed exits 0 but returns empty output). """ suffix = f"_job_{job_id}" if job_id else "" dest = Path(f"/tmp/gh_run_{run_id}{suffix}.log") base_args = ["run", "view", "--repo", repo, "--log-failed"] if job_id: base_args += ["--job", str(job_id)] else: base_args += [str(run_id)] try: out = _run(*base_args) except subprocess.CalledProcessError: out = "" # --log-failed exits 0 but returns empty output when there are no failed steps. if not out.strip(): full_args = ["run", "view", "--repo", repo, "--log"] if job_id: full_args += ["--job", str(job_id)] else: full_args += [str(run_id)] try: out = _run(*full_args) except subprocess.CalledProcessError as exc: dest.write_text( f"Failed to fetch logs for run {run_id}{suffix}: {exc.stderr}" ) return dest dest.write_text(out) return dest def _wait_for_run(repo: str, run_id: int, timeout_minutes: int = 30) -> str: """Poll every 30 s until the run in `repo` reaches a terminal state or times out. Returns the conclusion string (e.g. "success", "failure") or "timed_out". """ for _ in range(timeout_minutes * 2): status = get_run_status(repo, run_id) if status in TERMINAL_STATUSES: return status time.sleep(30) return "timed_out" def _poll_until_done(run_id: int, timeout_minutes: int = 30) -> str: """Poll every 30 s until the run in REPO reaches a terminal state or times out.""" return _wait_for_run(REPO, run_id, timeout_minutes) # ── AI tool functions ──────────────────────────────────────────────────────────── def get_workflow_run_status(run_id: int, repo: str) -> str: """Return the current status of a GitHub Actions workflow run. Returns "running" while in-progress, or a conclusion string once done (e.g. "success", "failure", "cancelled", "timed_out"). Args-> run_id: The workflow run ID to query. repo: GitHub repository in owner/repo format, e.g. "tidbcloud/auto-deploy". Any GitHub repository is accepted. """ return get_run_status(repo, run_id) def get_workflow_run_logs(run_id: int, repo: str, job_id: int = 0) -> DownloadedFile: """Download the logs of a GitHub Actions workflow run (or a specific job) to a local file. Tries to fetch only failed-step logs first; falls back to full logs if none. To fetch logs for a specific job, pass its job_id (use list_workflow_run_jobs to find it). Args-> run_id: The workflow run ID whose logs to fetch. repo: GitHub repository in owner/repo format, e.g. "tidbcloud/auto-deploy". Any GitHub repository is accepted. job_id: Optional job ID to narrow the download to a single job. When omitted, logs for the entire run are downloaded. Returns-> {"path": str, "size_bytes": int} Search the file with grep or read it with read_file. """ dest = download_run_logs(repo, run_id, job_id) return DownloadedFile(path=str(dest), size_bytes=dest.stat().st_size) def wait_for_workflow_run( run_id: int, repo: str, timeout_minutes: int = 30, ) -> WorkflowRunResult: """Wait for a workflow run to reach a terminal state. Polls every 30 seconds. Use this after triggering a deploy or destroy workflow to block until the run finishes, and also when resuming with a run_id returned with status=already_triggered by a deploy_* or destroy_* function. Args-> run_id: The workflow run ID returned by a deploy or destroy tool. repo: GitHub repository in owner/repo format, e.g. "tidbcloud/auto-deploy". Any GitHub repository is accepted. timeout_minutes: Maximum time to wait before returning "timed_out" (default 30). Returns-> {"run_id": int, "conclusion": str, "url": str} conclusion is one of: success, failure, cancelled, timed_out, action_required, skipped. If the deploy or destroy script returned already_done, skip this call and use the conclusion from that response directly. """ conclusion = _wait_for_run(repo, run_id, timeout_minutes) return WorkflowRunResult( run_id=run_id, conclusion=conclusion, url=f"https://github.com/{repo}/actions/runs/{run_id}", ) def list_workflow_run_jobs(run_id: int, repo: str) -> list[WorkflowJobSummary]: """List all jobs for a specific workflow run. Use this to find job IDs before calling get_workflow_run_logs with job_id. Args-> run_id: The workflow run ID to inspect. repo: GitHub repository in owner/repo format, e.g. "tidbcloud/auto-deploy". Any GitHub repository is accepted. Returns-> list of {"job_id": int, "name": str, "status": str, "conclusion": str, "url": str} "conclusion" is empty string when the job has not yet completed. Returns an empty list if the call fails. """ result = subprocess.run( [ "gh", "api", f"repos/{repo}/actions/runs/{run_id}/jobs", "--jq", ".jobs[] | {id, name, status, conclusion, html_url}", ], capture_output=True, text=True, ) if result.returncode != 0: return [] jobs = [] for line in result.stdout.strip().splitlines(): if not line.strip(): continue item: dict[str, Any] = json.loads(line) jobs.append( WorkflowJobSummary( job_id=item["id"], name=item.get("name") or "", status=item.get("status") or "", conclusion=item.get("conclusion") or "", url=item.get("html_url") or "", ) ) return jobs def list_workflow_runs( repo: str, workflow: str = "", branch: str = "", status: str = "", limit: int = 20, ) -> list[WorkflowRunSummary]: """List recent workflow runs in a GitHub repository. Args-> repo: GitHub repository in owner/repo format, e.g. "tidbcloud/auto-deploy". Any GitHub repository is accepted. workflow: Filter by workflow file name or ID, e.g. "deploy-aws-premium-region.yml". Omit to list runs across all workflows. branch: Filter by branch name, e.g. "main". status: Filter by run status — completed, success, failure, cancelled, in_progress, queued, or waiting. Omit to return all statuses. limit: Maximum number of results to return (default 20). Returns-> list of {"run_id", "name", "workflow", "branch", "status", "conclusion", "createdAt", "url"} "conclusion" is empty string when the run has not yet completed. Returns an empty list if the call fails. """ cmd = [ "gh", "run", "list", "--repo", repo, "--limit", str(limit), "--json", "databaseId,displayTitle,workflowName,headBranch,status,conclusion,createdAt,url", ] if workflow: cmd += ["--workflow", workflow] if branch: cmd += ["--branch", branch] if status: cmd += ["--status", status] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: return [] items: list[dict[str, Any]] = json.loads(result.stdout) return [ WorkflowRunSummary( run_id=item["databaseId"], name=item.get("displayTitle") or "", workflow=item.get("workflowName") or "", branch=item.get("headBranch") or "", status=item.get("status") or "", conclusion=item.get("conclusion") or "", createdAt=item.get("createdAt") or "", url=item.get("url") or "", ) for item in items ] # ── Capability class ───────────────────────────────────────────────────────────── @dataclass class GitHubWorkflowsCapability(CapabilityWithTools): """GitHub Actions workflow visibility: list, inspect, fetch logs, and wait.""" def get_toolset(self) -> FunctionToolset[Any]: return FunctionToolset( [ list_workflow_runs, list_workflow_run_jobs, get_workflow_run_status, get_workflow_run_logs, wait_for_workflow_run, ] ) def _instructions_header(self) -> str: return """\ ## GitHub Actions Functions Call these as `await function_name(...)` inside `run_code`. All functions require `repo` in owner/repo format (e.g. "tidbcloud/auto-deploy"). Any GitHub repository is accepted — not limited to the three managed repos. Use `list_workflow_runs` to find run IDs when you only know the repo or workflow name. Use `list_workflow_run_jobs` to find job IDs within a run (required before fetching job-level logs). Use `get_workflow_run_logs(run_id, repo)` for full-run logs, or pass `job_id` to narrow to one job. Use `get_workflow_run_status` / `wait_for_workflow_run` to inspect or block on a known run.\ """