salim0986/graph-bug-ai
0
1"""2GitHub API Client for Code Review System (Phase 5.1)3 4This module provides a comprehensive GitHub API client for:5- App installation authentication6- Token management and refresh7- Repository and PR data fetching8- Rate limit handling9- Comment posting (Phase 5.3)10 11Design Decisions:12- Use PyGithub library for GitHub API v313- Implement token caching to minimize API calls14- Handle rate limiting with exponential backoff15- Support both App authentication (for webhooks) and OAuth (for user actions)16"""17 18import os19import time20import asyncio21from collections import OrderedDict22from datetime import datetime, timedelta23from typing import Dict, List, Optional, Any24from dataclasses import dataclass25 26import jwt27from github import Github, GithubIntegration, Auth28from github.GithubException import GithubException, RateLimitExceededException29 30from .logger import setup_logger31 32logger = setup_logger(__name__)33 34 35# ========================================================================36# CONFIGURATION37# ========================================================================38 39@dataclass40class GitHubConfig:41 """GitHub API configuration"""42 43 # App credentials44 app_id: str45 private_key: str # PEM format46 47 # Rate limiting48 max_retries: int = 349 retry_delay_seconds: float = 2.050 51 # Token caching52 token_expiry_buffer_minutes: int = 5 # Refresh token 5 min before expiry53 54 @classmethod55 def from_env(cls) -> "GitHubConfig":56 """Load configuration from environment variables"""57 app_id = os.getenv("GITHUB_APP_ID")58 private_key_path = os.getenv("GITHUB_PRIVATE_KEY_PATH")59 private_key_content = os.getenv("GITHUB_PRIVATE_KEY")60 61 if not app_id:62 raise ValueError("GITHUB_APP_ID environment variable is required")63 64 # Load private key from file or environment variable65 if private_key_path and os.path.exists(private_key_path):66 with open(private_key_path, 'r') as f:67 private_key = f.read()68 elif private_key_content:69 # Handle escaped newlines (\n -> actual newlines)70 private_key = private_key_content.replace('\\n', '\n')71 else:72 raise ValueError("Either GITHUB_PRIVATE_KEY_PATH or GITHUB_PRIVATE_KEY must be set")73 74 return cls(75 app_id=app_id,76 private_key=private_key77 )78 79 80# ========================================================================81# TOKEN MANAGER82# ========================================================================83 84class TokenManager:85 """86 Manages GitHub App installation tokens87 88 Why this exists:89 - Installation tokens expire after 1 hour90 - Need to refresh tokens before they expire91 - Cache tokens to minimize API calls92 - Handle multiple installations (different repos)93 """94 95 def __init__(self, config: GitHubConfig):96 self.config = config97 self.integration = GithubIntegration(98 integration_id=config.app_id,99 private_key=config.private_key100 )101 102 # Token cache: {installation_id: (token, expiry_time)}103 self._token_cache: Dict[int, tuple[str, datetime]] = {}104 105 logger.info("TokenManager initialized")106 107 def get_installation_token(self, installation_id: int) -> str:108 """109 Get installation token (from cache or generate new)110 111 Args:112 installation_id: GitHub App installation ID113 114 Returns:115 str: Installation access token116 """117 # Check cache118 if installation_id in self._token_cache:119 token, expiry = self._token_cache[installation_id]120 121 # Check if token is still valid (with buffer)122 buffer = timedelta(minutes=self.config.token_expiry_buffer_minutes)123 if datetime.utcnow() + buffer < expiry:124 logger.debug(f"Using cached token for installation {installation_id}")125 return token126 127 # Generate new token128 logger.info(f"Generating new token for installation {installation_id}")129 auth = self.integration.get_access_token(installation_id)130 131 # Cache token132 expiry = datetime.utcnow() + timedelta(hours=1) # Tokens valid for 1 hour133 self._token_cache[installation_id] = (auth.token, expiry)134 135 return auth.token136 137 def invalidate_token(self, installation_id: int):138 """Invalidate cached token (e.g., on 401 error)"""139 if installation_id in self._token_cache:140 logger.info(f"Invalidating cached token for installation {installation_id}")141 del self._token_cache[installation_id]142 143 144# ========================================================================145# GITHUB API CLIENT146# ========================================================================147 148class GitHubClient:149 """150 GitHub API client with App authentication151 152 Features:153 - App installation authentication154 - Automatic token refresh155 - Rate limit handling156 - Retry logic with exponential backoff157 - Repository and PR data fetching158 - Comment posting (Phase 5.3)159 160 Usage:161 client = GitHubClient(config)162 pr_data = await client.get_pull_request("owner/repo", 123, installation_id)163 await client.post_review_comment(...)164 """165 166 def __init__(self, config: GitHubConfig):167 self.config = config168 self.token_manager = TokenManager(config)169 # LRU cache for fetched file contents. Keyed by "repo:ref:path".170 # Bounded at 1 000 entries to avoid unbounded memory growth on large repos171 # (1 000 × ~50 KB average file ≈ 50 MB worst case).172 self._file_cache: "OrderedDict[str, str]" = OrderedDict()173 self._file_cache_max = 1_000174 175 logger.info("GitHubClient initialized")176 177 def get_installation_token(self, installation_id: int) -> str:178 """179 Get installation access token (public method for repo cloning)180 181 Args:182 installation_id: GitHub App installation ID183 184 Returns:185 str: Installation access token for git operations186 """187 return self.token_manager.get_installation_token(installation_id)188 189 def _get_github_instance(self, installation_id: int) -> Github:190 """191 Get authenticated GitHub instance for installation192 193 Args:194 installation_id: GitHub App installation ID195 196 Returns:197 Github: Authenticated PyGithub instance198 """199 token = self.token_manager.get_installation_token(installation_id)200 auth = Auth.Token(token)201 return Github(auth=auth)202 203 async def _handle_rate_limit(self, github: Github):204 """205 Check rate limit and wait if necessary206 207 GitHub Rate Limits:208 - 5000 requests/hour for authenticated requests209 - Rate limit resets at a specific time210 """211 rate_limit = github.get_rate_limit()212 core = rate_limit.core213 214 if core.remaining < 100: # Less than 100 requests left215 reset_time = core.reset.timestamp()216 wait_time = max(0, reset_time - time.time())217 218 logger.warning(219 f"Rate limit low: {core.remaining}/{core.limit}. "220 f"Waiting {wait_time:.0f}s until reset"221 )222 223 await asyncio.sleep(wait_time + 1) # Wait until reset + 1s buffer224 225 async def _retry_on_error(self, func, *args, **kwargs):226 """227 Execute function with retry logic228 229 Handles:230 - Rate limit errors (wait and retry)231 - Transient network errors (exponential backoff)232 - Authentication errors (invalidate token and retry once)233 """234 for attempt in range(self.config.max_retries):235 try:236 return await asyncio.to_thread(func, *args, **kwargs)237 238 except RateLimitExceededException as e:239 logger.warning(f"Rate limit exceeded: {e}")240 # GitHub will provide reset time241 await asyncio.sleep(60) # Wait 1 minute242 243 except GithubException as e:244 if e.status == 401: # Unauthorized245 logger.warning("Authentication failed, invalidating token")246 installation_id = kwargs.get('installation_id')247 if installation_id:248 self.token_manager.invalidate_token(installation_id)249 250 if attempt < self.config.max_retries - 1:251 continue # Retry with new token252 raise253 254 elif e.status >= 500: # Server error255 wait_time = self.config.retry_delay_seconds * (2 ** attempt)256 logger.warning(f"GitHub server error {e.status}, retrying in {wait_time}s")257 await asyncio.sleep(wait_time)258 else:259 raise # Client error, don't retry260 261 except Exception as e:262 if attempt == self.config.max_retries - 1:263 raise264 265 wait_time = self.config.retry_delay_seconds * (2 ** attempt)266 logger.error(f"Error: {e}, retrying in {wait_time}s")267 await asyncio.sleep(wait_time)268 269 raise Exception(f"Failed after {self.config.max_retries} retries")270 271 # ====================================================================272 # REPOSITORY OPERATIONS273 # ====================================================================274 275 async def get_repository(276 self,277 repo_full_name: str,278 installation_id: int279 ) -> Dict[str, Any]:280 """281 Get repository information282 283 Args:284 repo_full_name: Repository full name (owner/repo)285 installation_id: GitHub App installation ID286 287 Returns:288 Dict with repository data289 """290 logger.info(f"Fetching repository: {repo_full_name}")291 292 def _fetch():293 github = self._get_github_instance(installation_id)294 repo = github.get_repo(repo_full_name)295 296 return {297 "id": repo.id,298 "name": repo.name,299 "full_name": repo.full_name,300 "owner": repo.owner.login,301 "description": repo.description,302 "language": repo.language,303 "default_branch": repo.default_branch,304 "private": repo.private,305 "created_at": repo.created_at.isoformat(),306 "updated_at": repo.updated_at.isoformat(),307 }308 309 return await self._retry_on_error(_fetch)310 311 async def get_file_content(312 self,313 repo_full_name: str,314 path: str,315 ref: str,316 installation_id: int317 ) -> str:318 """319 Get raw file content from GitHub repository.320 Caches results in memory to avoid redundant calls for the same file at the same commit.321 322 Args:323 repo_full_name: Repository full name (owner/repo)324 path: Path to the file325 ref: Commit SHA or branch name326 installation_id: GitHub App installation ID327 328 Returns:329 str: Raw file content330 """331 cache_key = f"{repo_full_name}:{ref}:{path}"332 if cache_key in self._file_cache:333 # Move to end to mark as recently used (LRU)334 self._file_cache.move_to_end(cache_key)335 return self._file_cache[cache_key]336 337 logger.info(f"Fetching file content: {repo_full_name}/{path} @ {ref}")338 339 def _fetch():340 github = self._get_github_instance(installation_id)341 repo = github.get_repo(repo_full_name)342 343 try:344 content_file = repo.get_contents(path, ref=ref)345 if isinstance(content_file, list):346 raise ValueError(f"Path '{path}' resolves to a directory, not a file.")347 348 # Skip non-parseable content types before touching decoded_content.349 # Symlinks and submodules look like tiny files but are not source code.350 if getattr(content_file, "type", None) in ("symlink", "submodule"):351 logger.debug(f"Skipping {content_file.type}: {repo_full_name}/{path}")352 return ""353 354 # GitHub API truncates files > 1 MB; warn so engineers know the355 # AST result will be incomplete rather than silently wrong.356 if getattr(content_file, "truncated", False):357 logger.warning(358 f"File truncated by GitHub API (> 1 MB): {repo_full_name}/{path} @ {ref}"359 )360 361 raw_bytes: bytes = content_file.decoded_content362 # Guard against binary files (images, compiled artifacts, etc.).363 # Null bytes are a reliable indicator of non-text content.364 if b"\x00" in raw_bytes:365 logger.debug(f"Skipping binary file: {repo_full_name}/{path}")366 return ""367 try:368 return raw_bytes.decode("utf-8")369 except UnicodeDecodeError:370 # Non-UTF-8 text file (e.g. Latin-1 encoded legacy source).371 # Fall back to lossy decode so the parser still gets something.372 logger.warning(373 f"Non-UTF-8 file {repo_full_name}/{path} — decoding with errors='replace'"374 )375 return raw_bytes.decode("utf-8", errors="replace")376 377 except GithubException as e:378 if e.status == 404:379 logger.warning(f"File not found: {repo_full_name}/{path} @ {ref}")380 return ""381 raise382 383 content = await self._retry_on_error(_fetch)384 385 # Evict oldest entry if cache is full (LRU eviction)386 if len(self._file_cache) >= self._file_cache_max:387 self._file_cache.popitem(last=False)388 self._file_cache[cache_key] = content389 return content390 391 # ====================================================================392 # PULL REQUEST OPERATIONS393 # ====================================================================394 395 async def get_pull_request(396 self,397 repo_full_name: str,398 pr_number: int,399 installation_id: int400 ) -> Dict[str, Any]:401 """402 Get pull request information403 404 Args:405 repo_full_name: Repository full name (owner/repo)406 pr_number: Pull request number407 installation_id: GitHub App installation ID408 409 Returns:410 Dict with PR data including files and diff411 """412 logger.info(f"Fetching PR: {repo_full_name}#{pr_number}")413 414 def _fetch():415 github = self._get_github_instance(installation_id)416 repo = github.get_repo(repo_full_name)417 pr = repo.get_pull(pr_number)418 419 # Get PR files with diffs420 files = []421 for file in pr.get_files():422 files.append({423 "filename": file.filename,424 "status": file.status, # added, removed, modified, renamed425 "additions": file.additions,426 "deletions": file.deletions,427 "changes": file.changes,428 "patch": file.patch if file.patch else "",429 "blob_url": file.blob_url,430 "raw_url": file.raw_url,431 })432 433 return {434 "number": pr.number,435 "title": pr.title,436 "body": pr.body or "",437 "state": pr.state, # open, closed438 "user": {439 "login": pr.user.login,440 "id": pr.user.id,441 "avatar_url": pr.user.avatar_url,442 },443 "created_at": pr.created_at.isoformat(),444 "updated_at": pr.updated_at.isoformat(),445 "base": {446 "ref": pr.base.ref,447 "sha": pr.base.sha,448 },449 "head": {450 "ref": pr.head.ref,451 "sha": pr.head.sha,452 },453 "files": files,454 "additions": pr.additions,455 "deletions": pr.deletions,456 "changed_files": pr.changed_files,457 "mergeable": pr.mergeable,458 "mergeable_state": pr.mergeable_state,459 }460 461 return await self._retry_on_error(_fetch)462 463 async def list_pull_requests(464 self,465 repo_full_name: str,466 installation_id: int,467 state: str = "open",468 limit: int = 30469 ) -> List[Dict[str, Any]]:470 """471 List pull requests in repository472 473 Args:474 repo_full_name: Repository full name (owner/repo)475 installation_id: GitHub App installation ID476 state: PR state (open, closed, all)477 limit: Maximum number of PRs to return478 479 Returns:480 List of PR data dictionaries481 """482 logger.info(f"Listing PRs for {repo_full_name} (state={state}, limit={limit})")483 484 def _fetch():485 github = self._get_github_instance(installation_id)486 repo = github.get_repo(repo_full_name)487 pulls = repo.get_pulls(state=state)488 489 results = []490 for pr in pulls[:limit]:491 results.append({492 "number": pr.number,493 "title": pr.title,494 "state": pr.state,495 "user": pr.user.login,496 "created_at": pr.created_at.isoformat(),497 "updated_at": pr.updated_at.isoformat(),498 })499 500 return results501 502 return await self._retry_on_error(_fetch)503 504 # ====================================================================505 # REVIEW OPERATIONS (Phase 5.3)506 # ====================================================================507 508 async def post_review_comment(509 self,510 repo_full_name: str,511 pr_number: int,512 body: str,513 installation_id: int,514 commit_id: Optional[str] = None,515 event: str = "COMMENT"516 ) -> Dict[str, Any]:517 """518 Post a review comment on a pull request519 520 Args:521 repo_full_name: Repository full name (owner/repo)522 pr_number: Pull request number523 body: Review comment body (markdown)524 installation_id: GitHub App installation ID525 commit_id: Specific commit to review (optional, uses latest if not provided)526 event: Review event type:527 - COMMENT: General comment (no approval/rejection)528 - APPROVE: Approve PR529 - REQUEST_CHANGES: Request changes530 531 Returns:532 Dict with review data533 534 Implementation in Phase 5.3535 """536 logger.info(f"Posting review to {repo_full_name}#{pr_number}")537 538 def _post():539 github = self._get_github_instance(installation_id)540 repo = github.get_repo(repo_full_name)541 pr = repo.get_pull(pr_number)542 543 # Use latest commit if not specified544 target_commit_id = commit_id if commit_id else pr.head.sha545 546 # Create review547 review = pr.create_review(548 body=body,549 commit=repo.get_commit(target_commit_id),550 event=event551 )552 553 return {554 "id": review.id,555 "user": review.user.login,556 "body": review.body,557 "state": review.state,558 "html_url": review.html_url,559 "submitted_at": review.submitted_at.isoformat() if review.submitted_at else None,560 }561 562 return await self._retry_on_error(_post)563 564 async def post_inline_comment(565 self,566 repo_full_name: str,567 pr_number: int,568 body: str,569 path: str,570 line: int,571 installation_id: int,572 commit_id: Optional[str] = None,573 side: str = "RIGHT"574 ) -> Dict[str, Any]:575 """576 Post an inline comment on a specific line in PR diff577 578 Args:579 repo_full_name: Repository full name580 pr_number: Pull request number581 body: Comment body582 path: File path in the PR583 line: Line number in the diff584 installation_id: GitHub App installation ID585 commit_id: Specific commit (optional)586 side: Which side of diff (LEFT for old, RIGHT for new)587 588 Returns:589 Dict with comment data590 591 Implementation in Phase 5.3592 """593 logger.info(f"Posting inline comment to {repo_full_name}#{pr_number} at {path}:{line}")594 595 def _post():596 github = self._get_github_instance(installation_id)597 repo = github.get_repo(repo_full_name)598 pr = repo.get_pull(pr_number)599 600 # Use latest commit if not specified601 target_commit_id = commit_id if commit_id else pr.head.sha602 603 # Create review comment (inline)604 comment = pr.create_review_comment(605 body=body,606 commit=repo.get_commit(target_commit_id),607 path=path,608 line=line,609 side=side610 )611 612 return {613 "id": comment.id,614 "path": comment.path,615 "line": comment.line,616 "body": comment.body,617 "user": comment.user.login,618 "html_url": comment.html_url,619 "created_at": comment.created_at.isoformat(),620 }621 622 return await self._retry_on_error(_post)623 624 async def get_existing_reviews(625 self,626 repo_full_name: str,627 pr_number: int,628 installation_id: int629 ) -> List[Dict[str, Any]]:630 """631 Get existing reviews on a PR (to avoid duplicate reviews)632 633 Args:634 repo_full_name: Repository full name635 pr_number: Pull request number636 installation_id: GitHub App installation ID637 638 Returns:639 List of existing reviews640 """641 logger.info(f"Fetching existing reviews for {repo_full_name}#{pr_number}")642 643 def _fetch():644 github = self._get_github_instance(installation_id)645 repo = github.get_repo(repo_full_name)646 pr = repo.get_pull(pr_number)647 648 reviews = []649 for review in pr.get_reviews():650 reviews.append({651 "id": review.id,652 "user": review.user.login,653 "body": review.body,654 "state": review.state,655 "submitted_at": review.submitted_at.isoformat() if review.submitted_at else None,656 })657 658 return reviews659 660 return await self._retry_on_error(_fetch)661 662 # ====================================================================663 # RATE LIMIT UTILITIES664 # ====================================================================665 666 async def get_rate_limit_status(self, installation_id: int) -> Dict[str, Any]:667 """668 Get current rate limit status669 670 Returns:671 Dict with rate limit info672 """673 github = self._get_github_instance(installation_id)674 rate_limit = github.get_rate_limit()675 676 return {677 "core": {678 "limit": rate_limit.core.limit,679 "remaining": rate_limit.core.remaining,680 "reset": rate_limit.core.reset.isoformat(),681 },682 "search": {683 "limit": rate_limit.search.limit,684 "remaining": rate_limit.search.remaining,685 "reset": rate_limit.search.reset.isoformat(),686 }687 }688 689 690# ========================================================================691# FACTORY FUNCTION692# ========================================================================693 694def create_github_client() -> GitHubClient:695 """Create GitHub client from environment configuration"""696 config = GitHubConfig.from_env()697 return GitHubClient(config)698 699 700# ========================================================================701# EXAMPLE USAGE702# ========================================================================703 704if __name__ == "__main__":705 async def main():706 # Create client707 client = create_github_client()708 709 # Example: Get PR data710 installation_id = 12345 # From webhook711 pr_data = await client.get_pull_request(712 repo_full_name="owner/repo",713 pr_number=123,714 installation_id=installation_id715 )716 717 print(f"PR #{pr_data['number']}: {pr_data['title']}")718 print(f"Files changed: {len(pr_data['files'])}")719 720 # Example: Post review721 await client.post_review_comment(722 repo_full_name="owner/repo",723 pr_number=123,724 body="# AI Code Review\n\nLooks good! ✅",725 installation_id=installation_id,726 event="APPROVE"727 )728 729 asyncio.run(main())730 