This commit is contained in:
238
src/github_client.py
Normal file
238
src/github_client.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""GitHub API client for Code Pattern Search CLI."""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
from ghapi.core import GhApi
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitExceededError(Exception):
|
||||
"""Raised when GitHub API rate limit is exceeded."""
|
||||
|
||||
reset_time: datetime
|
||||
message: str = "GitHub API rate limit exceeded"
|
||||
|
||||
|
||||
class GitHubClient:
|
||||
"""Client for interacting with GitHub API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: Optional[str] = None,
|
||||
verbose: bool = False,
|
||||
timeout: int = 30,
|
||||
) -> None:
|
||||
"""Initialize the GitHub client."""
|
||||
self.token = token
|
||||
self.verbose = verbose
|
||||
self.timeout = timeout
|
||||
self._api: Optional[GhApi] = None
|
||||
self._rate_limit_remaining: int = 60
|
||||
|
||||
@property
|
||||
def api(self) -> GhApi:
|
||||
"""Get or create the GhApi instance."""
|
||||
if self._api is None:
|
||||
headers = {}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"token {self.token}"
|
||||
|
||||
self._api = GhApi(
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
return self._api
|
||||
|
||||
def _handle_rate_limit(self, response: requests.Response) -> None:
|
||||
"""Handle rate limit headers from response."""
|
||||
remaining = response.headers.get("X-RateLimit-Remaining")
|
||||
reset = response.headers.get("X-RateLimit-Reset")
|
||||
|
||||
if remaining:
|
||||
self._rate_limit_remaining = int(remaining)
|
||||
|
||||
if reset:
|
||||
reset_time = datetime.fromtimestamp(int(reset))
|
||||
if self._rate_limit_remaining <= 0:
|
||||
raise RateLimitExceededError(
|
||||
reset_time=reset_time,
|
||||
message="GitHub API rate limit exceeded. Please wait or provide a token.",
|
||||
)
|
||||
|
||||
def _retry_with_backoff(
|
||||
self,
|
||||
func,
|
||||
*args,
|
||||
max_retries: int = 3,
|
||||
backoff_factor: float = 0.5,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
"""Execute function with exponential backoff on failure."""
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return result
|
||||
except RateLimitExceededError:
|
||||
raise
|
||||
except requests.exceptions.RequestException as e:
|
||||
last_exception = e
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
sleep_time = backoff_factor * (2 ** attempt)
|
||||
if self.verbose:
|
||||
print(f"Retry {attempt + 1}/{max_retries} after {sleep_time}s: {e}")
|
||||
time.sleep(sleep_time)
|
||||
else:
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise requests.exceptions.RequestException("Unknown error occurred") from e
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise requests.exceptions.RequestException("Unknown error occurred")
|
||||
|
||||
def search_repositories(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
stars_min: Optional[int] = None,
|
||||
stars_max: Optional[int] = None,
|
||||
per_page: int = 10,
|
||||
) -> list[Any]:
|
||||
"""Search for repositories matching criteria."""
|
||||
search_query = []
|
||||
|
||||
if query:
|
||||
search_query.append(query)
|
||||
|
||||
if language:
|
||||
search_query.append(f"language:{language}")
|
||||
|
||||
if stars_min is not None:
|
||||
search_query.append(f"stars:>={stars_min}")
|
||||
|
||||
if stars_max is not None:
|
||||
search_query.append(f"stars:<={stars_max}")
|
||||
|
||||
search_query.append("sort:stars")
|
||||
search_query.append("order:desc")
|
||||
|
||||
full_query = " ".join(search_query)
|
||||
|
||||
if self.verbose:
|
||||
print(f"Search query: {full_query}")
|
||||
|
||||
def _do_search() -> list[Any]:
|
||||
response = self.api.search.repos(
|
||||
q=full_query,
|
||||
per_page=per_page,
|
||||
page=1,
|
||||
)
|
||||
|
||||
if hasattr(response, 'headers'):
|
||||
self._handle_rate_limit(response)
|
||||
|
||||
return response.get("items", [])
|
||||
|
||||
return self._retry_with_backoff(_do_search)
|
||||
|
||||
def get_file_tree(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
recursive: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get file tree for a repository."""
|
||||
def _do_fetch() -> list[dict[str, Any]]:
|
||||
try:
|
||||
response = self.api.git_trees.get(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
recursive=1 if recursive else 0,
|
||||
)
|
||||
|
||||
if hasattr(response, 'headers'):
|
||||
self._handle_rate_limit(response)
|
||||
|
||||
if response.truncated:
|
||||
if self.verbose:
|
||||
print(f"Warning: File tree truncated for {owner}/{repo}")
|
||||
|
||||
return response.tree
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f"Error fetching file tree for {owner}/{repo}: {e}")
|
||||
return []
|
||||
|
||||
return self._retry_with_backoff(_do_fetch)
|
||||
|
||||
def get_file_content(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
path: str,
|
||||
) -> Optional[str]:
|
||||
"""Get the content of a file."""
|
||||
def _do_fetch() -> Optional[str]:
|
||||
try:
|
||||
response = self.api.repos.get_content(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
path=path,
|
||||
)
|
||||
|
||||
if hasattr(response, 'headers'):
|
||||
self._handle_rate_limit(response)
|
||||
|
||||
if isinstance(response, dict):
|
||||
if response.get("encoding") == "base64":
|
||||
import base64
|
||||
return base64.b64decode(response["content"]).decode("utf-8")
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f"Error fetching {path} from {owner}/{repo}: {e}")
|
||||
return None
|
||||
|
||||
return self._retry_with_backoff(_do_fetch)
|
||||
|
||||
def get_repo_info(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Get repository information."""
|
||||
def _do_fetch() -> Optional[dict[str, Any]]:
|
||||
try:
|
||||
response = self.api.repos.get(
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f"Error fetching repo info for {owner}/{repo}: {e}")
|
||||
return None
|
||||
|
||||
return self._retry_with_backoff(_do_fetch)
|
||||
|
||||
def get_rate_limit_status(self) -> dict[str, Any]:
|
||||
"""Get current rate limit status."""
|
||||
try:
|
||||
response = self.api.rate_limit.get()
|
||||
return {
|
||||
"remaining": self._rate_limit_remaining,
|
||||
"limit": response.get("resources", {}).get("core", {}).get("limit"),
|
||||
"reset": response.get("resources", {}).get("core", {}).get("reset"),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
Reference in New Issue
Block a user