CoolFace
Apppublic

salim0986/graph-bug-ai

sourceHugging Facemitupdated 4mo agoView on Hugging Face
0likes
github_client.py730 linesDownload Raw Back to src
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