Spaces:
Runtime error
Runtime error
| """Lightweight async GitHub API client used by the MCP server.""" | |
| from __future__ import annotations | |
| import base64 | |
| import binascii | |
| import os | |
| from dataclasses import dataclass | |
| from typing import Any, Dict, Iterable, List, Optional | |
| import httpx | |
| class GitHubAPIError(RuntimeError): | |
| """Raised when the GitHub API returns an error response.""" | |
| class GitHubClient: | |
| """Thin wrapper around the GitHub REST API.""" | |
| token: Optional[str] = None | |
| base_url: str = "https://api.github.com" | |
| timeout: float = 15.0 | |
| def __post_init__(self) -> None: | |
| if not self.token: | |
| self.token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GITHUB_ACCESS_TOKEN") | |
| self.base_url = self.base_url.rstrip("/") | |
| def _headers(self) -> Dict[str, str]: | |
| headers = { | |
| "Accept": "application/vnd.github+json", | |
| "User-Agent": "github-chatgpt-app", | |
| } | |
| if self.token: | |
| headers["Authorization"] = f"Bearer {self.token}" | |
| return headers | |
| async def _get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Any: | |
| url = f"{self.base_url}/{endpoint.lstrip('/')}" | |
| try: | |
| async with httpx.AsyncClient(timeout=self.timeout) as client: | |
| response = await client.get(url, headers=self._headers(), params=params) | |
| response.raise_for_status() | |
| return response.json() | |
| except httpx.HTTPStatusError as exc: | |
| detail = exc.response.json() if exc.response.content else {"message": exc.response.text} | |
| message = detail.get("message", "GitHub API error") | |
| raise GitHubAPIError(f"{message} (status {exc.response.status_code})") from exc | |
| except httpx.RequestError as exc: | |
| raise GitHubAPIError(f"Unable to reach GitHub: {exc}") from exc | |
| async def get_repo_info(self, owner: str, repo: str) -> Dict[str, Any]: | |
| return await self._get(f"repos/{owner}/{repo}") | |
| async def list_issues( | |
| self, | |
| owner: str, | |
| repo: str, | |
| state: str = "open", | |
| labels: Optional[Iterable[str]] = None, | |
| limit: int = 25, | |
| include_pull_requests: bool = False, | |
| ) -> List[Dict[str, Any]]: | |
| params: Dict[str, Any] = { | |
| "state": state, | |
| "per_page": min(max(limit, 1), 100), | |
| } | |
| if labels: | |
| params["labels"] = ",".join(labels) | |
| data = await self._get(f"repos/{owner}/{repo}/issues", params=params) | |
| if include_pull_requests: | |
| return data | |
| return [issue for issue in data if "pull_request" not in issue] | |
| async def list_pull_requests( | |
| self, | |
| owner: str, | |
| repo: str, | |
| state: str = "open", | |
| limit: int = 25, | |
| ) -> List[Dict[str, Any]]: | |
| params = { | |
| "state": state, | |
| "per_page": min(max(limit, 1), 100), | |
| } | |
| return await self._get(f"repos/{owner}/{repo}/pulls", params=params) | |
| async def get_readme(self, owner: str, repo: str, ref: Optional[str] = None) -> Dict[str, Any]: | |
| params = {"ref": ref} if ref else None | |
| try: | |
| data = await self._get(f"repos/{owner}/{repo}/readme", params=params) | |
| except GitHubAPIError as exc: | |
| message = str(exc) | |
| if "status 404" in message: | |
| return { | |
| "name": None, | |
| "path": None, | |
| "sha": None, | |
| "content": "", | |
| "error": "README not found", | |
| } | |
| raise | |
| raw_content = data.get("content", "") or "" | |
| encoding = (data.get("encoding") or "base64").lower() | |
| decoded = "" | |
| error: Optional[str] = None | |
| if encoding == "base64": | |
| try: | |
| decoded_bytes = base64.b64decode(raw_content, validate=False) | |
| decoded = decoded_bytes.decode("utf-8", errors="replace") | |
| except (binascii.Error, UnicodeDecodeError): | |
| error = "README content could not be decoded from base64." | |
| else: | |
| if isinstance(raw_content, str): | |
| decoded = raw_content | |
| else: | |
| try: | |
| decoded = raw_content.decode("utf-8", errors="replace") | |
| except Exception: | |
| decoded = str(raw_content) | |
| error = f"Unsupported README encoding: {encoding}" | |
| return { | |
| "name": data.get("name"), | |
| "path": data.get("path"), | |
| "sha": data.get("sha"), | |
| "content": decoded, | |
| "error": error, | |
| } | |
| async def list_contributors( | |
| self, | |
| owner: str, | |
| repo: str, | |
| limit: int = 25, | |
| include_anonymous: bool = False, | |
| ) -> List[Dict[str, Any]]: | |
| params = { | |
| "per_page": min(max(limit, 1), 100), | |
| "anon": str(include_anonymous).lower(), | |
| } | |
| return await self._get(f"repos/{owner}/{repo}/contributors", params=params) | |
| async def list_files( | |
| self, | |
| owner: str, | |
| repo: str, | |
| ref: str = "main", | |
| directory: Optional[str] = None, | |
| limit: int = 200, | |
| ) -> List[Dict[str, Any]]: | |
| params = {"recursive": "1"} | |
| data = await self._get(f"repos/{owner}/{repo}/git/trees/{ref}", params=params) | |
| tree = data.get("tree", []) | |
| if directory: | |
| normalized = directory.strip("/") | |
| tree = [node for node in tree if node.get("path", "").startswith(normalized)] | |
| limited = tree[: min(max(limit, 1), len(tree))] | |
| return [ | |
| { | |
| "path": node.get("path"), | |
| "type": node.get("type"), | |
| "size": node.get("size"), | |
| "sha": node.get("sha"), | |
| } | |
| for node in limited | |
| ] | |
| async def search_repositories( | |
| self, | |
| query: str, | |
| sort: Optional[str] = None, | |
| order: str = "desc", | |
| limit: int = 25, | |
| ) -> List[Dict[str, Any]]: | |
| params: Dict[str, Any] = { | |
| "q": query, | |
| "per_page": min(max(limit, 1), 100), | |
| "order": order, | |
| } | |
| if sort: | |
| params["sort"] = sort | |
| data = await self._get("search/repositories", params=params) | |
| return data.get("items", []) | |
| __all__ = ["GitHubClient", "GitHubAPIError"] |