import logging import base64 import asyncio import httpx from typing import Tuple, Optional # Настройка локального логгера для слоя GitHub logger = logging.getLogger("CyberAgentGitHub") class GitHubClient: def __init__(self, token: str, repo: str, branch: str = "main"): """ Асинхронный клиент для безопасного взаимодействия с GitHub REST API с поддержкой пула соединений httpx. """ self.token = token self.repo = repo self.branch = branch # Стабильная версия API GitHub и авторизация self.headers = { "Authorization": f"Bearer {token}" if token else "", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" } # Внутренний асинхронный клиент self._client: Optional[httpx.AsyncClient] = None def _get_client(self) -> httpx.AsyncClient: """ Ленивая инициализация HTTP-клиента для предотвращения конфликтов с Event Loop при импорте модуля. """ if self._client is None or self._client.is_closed: self._client = httpx.AsyncClient(headers=self.headers, timeout=15.0) return self._client async def close(self): """Корректная утилизация сетевых ресурсов пула""" if self._client and not self._client.is_closed: await self._client.aclose() logger.info("Пул соединений с GitHub успешно закрыт.") async def get_file(self, target_path: str) -> Tuple[Optional[str], Optional[str]]: """ Асинхронно возвращает кортеж (контент, sha). Если файл отсутствует (404), возвращает ("", None). В случае критического сбоя сети возвращает (None, None). """ if not self.token: logger.error("Критическая ошибка: GitHub Token не задан.") return None, None url = f"https://api.github.com/repos/{self.repo}/contents/{target_path}" params = {"ref": self.branch} client = self._get_client() for attempt in range(3): try: res = await client.get(url, params=params) if res.status_code == 200: data = res.json() if "content" not in data: logger.error(f"Указанный путь {target_path} не содержит контента (возможно, это директория).") return None, None # Очистка контента от переносов строк, которые генерирует GitHub для Base64 raw_b64 = data["content"].replace("\n", "").replace("\r", "") decoded_content = base64.b64decode(raw_b64).decode("utf-8") return decoded_content, data["sha"] elif res.status_code == 404: logger.info(f"Файл {target_path} не найден в ветке {self.branch}. Он будет создан.") return "", None else: logger.warning(f"[Попытка {attempt+1}/3] Неожиданный ответ GitHub API [Get]: {res.status_code}") except (httpx.HTTPError, httpx.RequestError) as e: logger.warning(f"[Попытка {attempt+1}/3] Сетевой сбой при обращении к репозиторию: {str(e)}") if attempt < 2: await asyncio.sleep(0.5 * (attempt + 1)) return None, None async def commit_file(self, target_path: str, new_content: str, message: str) -> bool: """ Безопасно создает или атомарно обновляет файл в репозитории. Исключает перезапись данных при нарушении синхронизации (Race Conditions). """ # Важно: вызываем асинхронную версию get_file через await current_content, sha = await self.get_file(target_path) # Защитный барьер: прерываем коммит, если состояние ветки неизвестно if current_content is None and sha is None: logger.error(f"Отмена фиксации коммита для {target_path}: целостность ветки под вопросом.") return False # Интеллектуальная оптимизация: не тратим лимиты запросов, если код идентичен if current_content == new_content and sha is not None: logger.info(f"Изменения в локальной матрице файла {target_path} отсутствуют. Коммит отменен.") return True url = f"https://api.github.com/repos/{self.repo}/contents/{target_path}" payload = { "message": message, "content": base64.b64encode(new_content.encode("utf-8")).decode("utf-8"), "branch": self.branch } if sha: payload["sha"] = sha client = self._get_client() for attempt in range(3): try: res = await client.put(url, json=payload) if res.status_code in [200, 201]: logger.info(f"Файл {target_path} успешно закоммичен в GitHub репозиторий.") return True else: logger.warning(f"[Попытка {attempt+1}/3] Ошибка фиксации коммита [Put]: {res.status_code} — {res.text}") except (httpx.HTTPError, httpx.RequestError) as e: logger.warning(f"[Попытка {attempt+1}/3] Исключение сети при отправке коммита: {str(e)}") if attempt < 2: await asyncio.sleep(0.5 * (attempt + 1)) logger.error(f"Критический сбой: не удалось синхронизировать {target_path} после 3 попыток.") return False