Web3CyberAgent / api /github_client.py
web3cyberservices
Update github_client.py
22dad7b unverified
Raw
History Blame Contribute Delete
6.81 kB
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