"""GitHub API helpers: fetch PR data, post reviews/comments/labels/check runs.""" from __future__ import annotations import logging from typing import TYPE_CHECKING import stamina from .retry import is_retryable if TYPE_CHECKING: import httpx log = logging.getLogger(__name__) GITHUB_API = "https://api.github.com" MAX_DIFF_CHARS = 60_000 MAX_PAGES = 50 @stamina.retry(on=is_retryable, attempts=3) async def fetch_pr_diff( client: httpx.AsyncClient, owner: str, repo: str, pr_number: int, token: str, ) -> str: """Fetch the unified diff for a pull request via GitHub API.""" resp = await client.get( f"{GITHUB_API}/repos/{owner}/{repo}/pulls/{pr_number}", headers={ "Authorization": f"token {token}", "Accept": "application/vnd.github.diff", }, ) resp.raise_for_status() return resp.text @stamina.retry(on=is_retryable, attempts=3) async def fetch_pr_files( client: httpx.AsyncClient, owner: str, repo: str, pr_number: int, token: str, ) -> list[dict]: """Fetch the list of changed files for a pull request (paginated).""" files: list[dict] = [] page = 1 while True: resp = await client.get( f"{GITHUB_API}/repos/{owner}/{repo}/pulls/{pr_number}/files", headers={ "Authorization": f"token {token}", "Accept": "application/vnd.github+json", }, params={"per_page": 100, "page": page}, ) resp.raise_for_status() batch = resp.json() if not batch: break files.extend(batch) page += 1 if page > MAX_PAGES: log.warning( "Pagination cap reached fetching files for %s/%s#%d (%d pages)", owner, repo, pr_number, MAX_PAGES, ) break return files @stamina.retry(on=is_retryable, attempts=3) async def fetch_pr_details( client: httpx.AsyncClient, owner: str, repo: str, pr_number: int, token: str, ) -> dict: """Fetch PR metadata (head/base refs, title, etc.).""" resp = await client.get( f"{GITHUB_API}/repos/{owner}/{repo}/pulls/{pr_number}", headers={ "Authorization": f"token {token}", "Accept": "application/vnd.github+json", }, ) resp.raise_for_status() return resp.json() @stamina.retry(on=is_retryable, attempts=3) async def fetch_commit_diff( client: httpx.AsyncClient, owner: str, repo: str, sha: str, token: str, ) -> str: """Fetch the unified diff for a single commit via GitHub API.""" resp = await client.get( f"{GITHUB_API}/repos/{owner}/{repo}/commits/{sha}", headers={ "Authorization": f"token {token}", "Accept": "application/vnd.github.diff", }, ) resp.raise_for_status() return resp.text @stamina.retry(on=is_retryable, attempts=3) async def fetch_repo_labels( client: httpx.AsyncClient, owner: str, repo: str, token: str, ) -> list[str]: """Fetch all label names from a repository.""" labels: list[str] = [] page = 1 while True: resp = await client.get( f"{GITHUB_API}/repos/{owner}/{repo}/labels", headers={ "Authorization": f"token {token}", "Accept": "application/vnd.github+json", }, params={"per_page": 100, "page": page}, ) resp.raise_for_status() batch = resp.json() if not batch: break labels.extend(item["name"] for item in batch) page += 1 if page > MAX_PAGES: log.warning( "Pagination cap reached fetching labels for %s/%s (%d pages)", owner, repo, MAX_PAGES, ) break return labels @stamina.retry(on=is_retryable, attempts=3) async def post_review( client: httpx.AsyncClient, owner: str, repo: str, pr_number: int, body: str, event: str, token: str, ) -> None: """Submit a PR review (COMMENT, APPROVE, or REQUEST_CHANGES).""" resp = await client.post( f"{GITHUB_API}/repos/{owner}/{repo}/pulls/{pr_number}/reviews", headers={ "Authorization": f"token {token}", "Accept": "application/vnd.github+json", }, json={"body": body, "event": event}, ) resp.raise_for_status() @stamina.retry(on=is_retryable, attempts=3) async def post_comment( client: httpx.AsyncClient, owner: str, repo: str, issue_number: int, body: str, token: str, ) -> None: """Post a comment on a PR or issue.""" resp = await client.post( f"{GITHUB_API}/repos/{owner}/{repo}/issues/{issue_number}/comments", headers={ "Authorization": f"token {token}", "Accept": "application/vnd.github+json", }, json={"body": body}, ) resp.raise_for_status() @stamina.retry(on=is_retryable, attempts=3) async def add_labels( client: httpx.AsyncClient, owner: str, repo: str, issue_number: int, labels: list[str], token: str, ) -> None: """Add labels to an issue or PR.""" resp = await client.post( f"{GITHUB_API}/repos/{owner}/{repo}/issues/{issue_number}/labels", headers={ "Authorization": f"token {token}", "Accept": "application/vnd.github+json", }, json={"labels": labels}, ) resp.raise_for_status() @stamina.retry(on=is_retryable, attempts=3) async def create_check_run( client: httpx.AsyncClient, owner: str, repo: str, head_sha: str, name: str, conclusion: str, output: dict, token: str, ) -> None: """Create a check run on a commit.""" resp = await client.post( f"{GITHUB_API}/repos/{owner}/{repo}/check-runs", headers={ "Authorization": f"token {token}", "Accept": "application/vnd.github+json", }, json={ "name": name, "head_sha": head_sha, "status": "completed", "conclusion": conclusion, "output": output, }, ) resp.raise_for_status() def build_file_summary(files: list[dict]) -> str: """Build a one-line-per-file summary of changed files.""" lines: list[str] = [] for f in files: name = f["filename"] status = f["status"] adds = f.get("additions", 0) dels = f.get("deletions", 0) lines.append(f" {status:10s} {name} (+{adds}/-{dels})") return "\n".join(lines) def truncate_diff(diff: str, max_chars: int = MAX_DIFF_CHARS) -> str: """Truncate diff to max_chars, appending a note if cut.""" if len(diff) <= max_chars: return diff return diff[:max_chars] + "\n\n... (diff truncated, full repo available)"