"""GitHub API client.""" import logging import time from typing import Any, Optional import requests from config import GitHubConfig from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry LOGGER = logging.getLogger(__name__) class GitHubClient: """Thin wrapper around the GitHub API.""" _GITHUB_API_URL = 'https://api.github.com' _GITHUB_API_VERSION = '2022-11-28' _EXCLUDED_REPOS = ('theorchard/collab',) _PER_PAGE = 30 _REQUEST_TIMEOUT = 30 def __init__(self, cfg: GitHubConfig) -> None: """Initialize the GitHubClient. :param cfg: GitHub configuration with token and settings. """ self.cfg = cfg self._session = requests.Session() retries = Retry( total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504] ) self._session.mount('https://', HTTPAdapter(max_retries=retries)) self._session.headers.update( { 'Authorization': f'Bearer {self.cfg.github_token}', 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': self._GITHUB_API_VERSION, } ) def _paginate_code_search(self, query: str) -> list[dict]: """Execute a paginated GitHub code search. Handles pagination, rate limiting, and JSON parsing for all code search operations. :param query: The fully-constructed GitHub code search query string. :return: List of search result items across all pages. """ params: dict[str, Any] = {'q': query} results: list[dict] = [] page = 1 while True: params['page'] = page params['per_page'] = self._PER_PAGE resp = self._session.get( f'{self._GITHUB_API_URL}/search/code', params=params, timeout=self._REQUEST_TIMEOUT, ) resp.raise_for_status() if 'X-RateLimit-Remaining' in resp.headers: remaining = resp.headers['X-RateLimit-Remaining'] if remaining.isdigit() and int(remaining) <= 1: reset_time = resp.headers.get('X-RateLimit-Reset') if reset_time and reset_time.isdigit(): sleep_seconds = int(reset_time) - int(time.time()) if sleep_seconds > 0: adjusted_sleep = sleep_seconds + 10 LOGGER.warning( 'Rate limit near exhaustion. Sleeping for %d seconds.', adjusted_sleep, ) time.sleep(adjusted_sleep) else: # Reset time is already in the past; sleep briefly # to avoid a tight retry loop caused by clock skew. LOGGER.warning( 'Rate limit reset time in past; ' 'sleeping 60 seconds as fallback.' ) time.sleep(60) # Retry the same page continue try: data = resp.json() except requests.exceptions.JSONDecodeError as exc: raise RuntimeError( f'GitHub API returned non-JSON response (status {resp.status_code})' ) from exc items = data.get('items', []) if not items: break results.extend(items) if len(items) < self._PER_PAGE: break page += 1 return results def _build_org_query( self, base_query: str, org: str, repo_filter: Optional[str] = None, ) -> str: """Build a GitHub code search query scoped to an org. :param base_query: The search term portion of the query. :param org: The GitHub organization name. :param repo_filter: Optional repository name filter. Must be fully qualified as ``owner/repo``; GitHub's ``repo:`` qualifier silently matches nothing for a bare repository name. :return: The fully-constructed query string. :raises ValueError: If repo_filter is not in ``owner/repo`` form. """ query_parts = [f'{base_query} org:{org} in:file language:Terraform'] if repo_filter: if '/' not in repo_filter: raise ValueError( f'repo_filter must be fully qualified as "owner/repo", ' f'got {repo_filter!r}. A bare repository name matches ' f'nothing in GitHub code search.' ) query_parts.append(f'repo:{repo_filter}') for excluded in self._EXCLUDED_REPOS: query_parts.append(f'-repo:{excluded}') return ' '.join(query_parts) def search_text_occurrences_in_org( self, org: str, search_text: str, repo_filter: Optional[str] = None ) -> list[dict]: """Search for text occurrences across all repos in a GitHub organization. :param org: The GitHub organization name. :param search_text: The text to search for. :param repo_filter: Optional repository name filter. :return: List of search result items across all repos in the org. """ query = self._build_org_query(f'"{search_text}"', org, repo_filter) return self._paginate_code_search(query) def create_issue( self, repo: str, title: str, body: Optional[str] = None, assignees: Optional[list[str]] = None, ) -> dict: """Create a new issue in the specified repository. :param repo: The GitHub repository in the format "owner/repo". :param title: The title of the issue. :param body: The body content of the issue. :param assignees: Optional list of usernames to assign the issue to. :return: The created issue data. """ payload: dict[str, Any] = {'title': title} if body: payload['body'] = body if assignees: payload['assignees'] = assignees resp = self._session.post( f'{self._GITHUB_API_URL}/repos/{repo}/issues', json=payload, timeout=self._REQUEST_TIMEOUT, ) resp.raise_for_status() return resp.json()