"""Git and GitHub PR capabilities for autopilot agents. Provides five tools: - git: run any git command in a local repository checkout. - get_pr_info: fetch metadata, description, and changed files for a PR. - get_pr_diff: download the full diff of a PR to a local file. - list_prs: list pull requests with filtering and GitHub search syntax. - create_pr: open a new GitHub pull request. git and create_pr operate on local checkouts; pass the filesystem path via cwd or head. The remaining three tools call the GitHub API and accept any owner/repo slug. """ from __future__ import annotations import json import subprocess 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 # ── Return types ────────────────────────────────────────────────────────────── class GitResult(BaseModel): """Return value of git.""" stdout: str stderr: str returncode: int class PRFile(BaseModel): """A single file changed in a pull request.""" path: str additions: int deletions: int changeType: str # ADDED | MODIFIED | DELETED | RENAMED | COPIED class PRInfo(BaseModel): """Return value of get_pr_info and individual entries returned by list_prs.""" number: int title: str branch: str base: str url: str state: str = "" author: str = "" createdAt: str = "" mergedAt: str = "" closedAt: str = "" body: str = "" files: list[PRFile] = [] error: str = "" class PRCreated(BaseModel): """Return value of create_pr.""" url: str number: int repo: str error: str = "" # ── Tool functions ──────────────────────────────────────────────────────────── def git(args: list[str], cwd: str) -> GitResult: """Run an arbitrary git command in a local repository checkout. Args-> args: Git subcommand and arguments, e.g. ["commit", "-m", "fix: typo"] or ["log", "--oneline", "-10"]. cwd: Absolute path to the local repository checkout, e.g. "/home/user/auto-deploy". Returns-> {"stdout": str, "stderr": str, "returncode": int} A non-zero returncode indicates failure; inspect result["stderr"] for details. """ result = subprocess.run( ["git", *args], capture_output=True, text=True, cwd=cwd, ) return GitResult( stdout=result.stdout, stderr=result.stderr, returncode=result.returncode, ) def get_pr_info(pr_number: int, repo: str) -> PRInfo: """Fetch metadata, description, and changed files for a GitHub pull request. Uses GitHub-stored data so results remain accurate even after the PR branch is deleted or the base branch moves on. For the full code diff, use get_pr_diff which writes it to a local file. Args-> pr_number: Pull request number. repo: GitHub repository in owner/repo format, e.g. "tidbcloud/auto-deploy". Any GitHub repository is accepted. Returns-> {"number": int, "title": str, "branch": str, "base": str, "url": str, "state": str, "author": str, "createdAt": str, "mergedAt": str, "closedAt": str, "body": str, "files": list[{"path", "additions", "deletions", "changeType"}], "error": str} Check result["error"] first; non-empty means the call failed. """ view_result = subprocess.run( [ "gh", "pr", "view", str(pr_number), "--repo", repo, "--json", "number,title,headRefName,baseRefName,url,state,author,createdAt,mergedAt,closedAt,body,files", ], capture_output=True, text=True, ) if view_result.returncode != 0: return PRInfo( number=0, title="", branch="", base="", url="", error=view_result.stderr.strip(), ) data: dict[str, Any] = json.loads(view_result.stdout) files = [ PRFile( path=f["path"], additions=f["additions"], deletions=f["deletions"], changeType=f["changeType"], ) for f in data.get("files", []) ] author_raw = data.get("author") or {} return PRInfo( number=data["number"], title=data["title"], branch=data["headRefName"], base=data["baseRefName"], url=data["url"], state=data.get("state") or "", author=( author_raw.get("login", "") if isinstance(author_raw, dict) else str(author_raw) ), createdAt=data.get("createdAt") or "", mergedAt=data.get("mergedAt") or "", closedAt=data.get("closedAt") or "", body=data.get("body") or "", files=files, ) def get_pr_diff(pr_number: int, repo: str) -> DownloadedFile: """Download the full diff of a GitHub pull request to a local file. Uses GitHub-stored data so the diff remains accurate even after the PR branch is deleted or the base branch moves on. Args-> pr_number: Pull request number. repo: GitHub repository in owner/repo format, e.g. "tidbcloud/auto-deploy". Any GitHub repository is accepted. Returns-> {"path": str, "size_bytes": int} Read the file with read_file or search with grep. If the diff could not be fetched, the file contains an error message. """ safe_repo = repo.replace("/", "_") dest = Path(f"/tmp/gh_pr_{pr_number}_{safe_repo}.diff") result = subprocess.run( ["gh", "pr", "diff", str(pr_number), "--repo", repo], capture_output=True, text=True, ) if result.returncode != 0: dest.write_text(f"ERROR: {result.stderr.strip()}\n") else: dest.write_text(result.stdout) return DownloadedFile(path=str(dest), size_bytes=dest.stat().st_size) def list_prs( repo: str, state: str = "merged", limit: int = 20, author: str = "", base: str = "", label: str = "", search: str = "", ) -> list[PRInfo]: """List pull requests in a repository. Args-> repo: GitHub repository in owner/repo format, e.g. "tidbcloud/auto-deploy". Any GitHub repository is accepted. state: Filter by state — merged, open, closed, or all (default: merged). limit: Maximum number of results to return (default: 20). author: Filter by author login, e.g. "github-actions[bot]". base: Filter by base branch, e.g. "main". label: Filter by label name. search: GitHub search query string for advanced filtering, e.g. "merged:>2024-01-01" or "is:unmerged". Appended to the --search flag and combined with the other filters. Returns-> list of {"number", "title", "url", "state", "author", "branch", "base", "createdAt", "mergedAt", "closedAt"} "body" and "files" are always empty (not fetched by list). Returns an empty list if the call fails. """ cmd = [ "gh", "pr", "list", "--repo", repo, "--state", state, "--limit", str(limit), "--json", "number,title,url,state,author,headRefName,baseRefName,createdAt,mergedAt,closedAt", ] if author: cmd += ["--author", author] if base: cmd += ["--base", base] if label: cmd += ["--label", label] if search: cmd += ["--search", search] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: return [] items: list[dict[str, Any]] = json.loads(result.stdout) return [ PRInfo( number=item["number"], title=item["title"], url=item["url"], state=item.get("state") or "", author=( item["author"]["login"] if isinstance(item["author"], dict) else str(item["author"]) ), branch=item["headRefName"], base=item["baseRefName"], createdAt=item.get("createdAt") or "", mergedAt=item.get("mergedAt") or "", closedAt=item.get("closedAt") or "", ) for item in items ] def create_pr( title: str, body: str, repo: str, head: str, base: str = "main", ) -> PRCreated: """Open a new GitHub pull request. Push the branch before calling this function: await git(["push", "--set-upstream", "origin", branch], cwd=...) Args-> title: Pull request title. body: Pull request body (markdown). repo: Target repository in owner/repo format, e.g. "tidbcloud/auto-deploy". head: Head branch name to use as the PR source, e.g. "fix/my-change". base: Base branch to merge into (default: main). Returns-> {"url": str, "number": int, "repo": str, "error": str} Check result["error"] first; non-empty means the call failed. """ result = subprocess.run( [ "gh", "pr", "create", "--repo", repo, "--head", head, "--base", base, "--title", title, "--body", body, ], capture_output=True, text=True, ) if result.returncode != 0: return PRCreated(url="", number=0, repo=repo, error=result.stderr.strip()) url = result.stdout.strip() try: number = int(url.rstrip("/").split("/")[-1]) except (ValueError, IndexError): number = 0 return PRCreated(url=url, number=number, repo=repo) # ── Capability class ────────────────────────────────────────────────────────── @dataclass class GitCapability(CapabilityWithTools): """Git and GitHub PR operations across managed repositories. Exposes five tools: - git: run any git command in a local checkout. - get_pr_info: fetch PR metadata, description, and changed file list. - get_pr_diff: download the full PR diff to a local file. - list_prs: list pull requests with filtering and GitHub search syntax. - create_pr: open a new pull request. """ def get_toolset(self) -> FunctionToolset[Any]: return FunctionToolset([git, get_pr_info, get_pr_diff, list_prs, create_pr]) def _instructions_header(self) -> str: return """\ ## Git and PR Functions Call these as `await function_name(...)` inside `run_code`. `git` and `create_pr` operate on local checkouts: - `git` takes `cwd` — the absolute path to the local repository. - `create_pr` takes `repo` (owner/repo) and `head` (branch name); push the branch with `git(["push", "--set-upstream", "origin", branch], cwd=...)` first. `get_pr_info`, `get_pr_diff`, and `list_prs` call the GitHub API and accept any `repo` in owner/repo format (e.g. "tidbcloud/auto-deploy"). They are not limited to the three managed repositories. `git` always returns stdout/stderr/returncode without raising — check returncode to detect failures. `list_prs` supports GitHub search syntax via the `search` parameter (e.g. "merged:>2024-01-01").\ """ __all__ = [ "DownloadedFile", "GitCapability", "GitResult", "PRCreated", "PRFile", "PRInfo", "create_pr", "get_pr_diff", "get_pr_info", "git", "list_prs", ]