app
Webhook-сервер LLM code review бота.
Слушает события pull_request из Gitea (opened/synchronize/reopened), запускает ревью в фоне и постит результат через Gitea API.
1""" 2Webhook-сервер LLM code review бота. 3 4Слушает события pull_request из Gitea (opened/synchronize/reopened), 5запускает ревью в фоне и постит результат через Gitea API. 6""" 7 8from __future__ import annotations 9 10import hashlib 11import hmac 12import json 13import logging 14import os 15from contextlib import asynccontextmanager 16import sys 17 18from apscheduler.schedulers.asyncio import AsyncIOScheduler 19from apscheduler.triggers.cron import CronTrigger 20from e_lib import Config as EConfig 21from e_lib.assistants import LangfuseCleanerAssistant 22from fastapi import BackgroundTasks, FastAPI, Request 23from fastapi.responses import JSONResponse 24from loguru import logger 25 26from config import Config 27from gitea_client import GiteaClient 28from langfuse_client import get_langfuse 29from models import AppConfig 30from review import run_review 31from reviewer import cache_prompt 32 33APP_VERSION = os.getenv("PRODUCTS_VERSION", "dev") 34 35config = Config.from_env() 36client = GiteaClient(config) 37 38# Gitea 1.19 использует "synchronized" (в отличие от GitHub "synchronize"). 39PR_ACTIONS = ("opened", "synchronized", "synchronize", "reopened", "ready_for_review") 40 41CONFIG_PATH = os.getenv( 42 "CONFIG_PATH", 43 os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml"), 44) 45 46_scheduler: AsyncIOScheduler | None = None 47 48 49class InterceptHandler(logging.Handler): 50 """ 51 Перехватчик логов из стандартного logging в loguru. 52 53 Перенаправляет все сообщения из логгеров uvicorn и fastapi 54 в форматированный вывод loguru. 55 """ 56 57 def emit(self, record): 58 """ 59 Обрабатывает и перенаправляет запись лога в loguru. 60 61 Args: 62 record: Запись лога из стандартного модуля logging 63 """ 64 try: 65 level = logger.level(record.levelname).name 66 except ValueError: 67 level = record.levelno 68 69 frame, depth = logging.currentframe(), 2 70 while frame.f_code.co_filename == logging.__file__: 71 frame = frame.f_back 72 depth += 1 73 74 logger.opt(depth=depth, exception=record.exc_info).log( 75 level, record.getMessage() 76 ) 77 78 79def setup_logging(): 80 """ 81 Настраивает логирование приложения. 82 83 Перенаправляет логи uvicorn и fastapi в loguru с цветным 84 форматированным выводом в stdout. Уровень DEBUG для всех 85 сообщений приложения. 86 """ 87 logger.remove() 88 89 logger.add( 90 sys.stdout, 91 format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>", 92 level="DEBUG", 93 colorize=True, 94 ) 95 96 intercept_handler = InterceptHandler() 97 98 root_logger = logging.getLogger() 99 root_logger.handlers = [intercept_handler] 100 root_logger.setLevel(logging.INFO) 101 102 for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"]: 103 logging_logger = logging.getLogger(logger_name) 104 logging_logger.handlers = [intercept_handler] 105 logging_logger.setLevel(logging.INFO) 106 logging_logger.propagate = False 107 108 109def _to_cron(time_str: str) -> str: 110 """Преобразует время 'ЧЧ:ММ' в cron-выражение.""" 111 h, m = time_str.split(":") 112 return f"{m} {h} * * *" 113 114 115@asynccontextmanager 116async def lifespan(_: FastAPI): 117 """ 118 Инициализирует Langfuse и планирует фонового ассистента очистки трейсов 119 (LangfuseCleanerAssistant из e_lib) по конфигу e_lib.Config (config.yaml). 120 """ 121 global _scheduler 122 setup_logging() 123 products_log = None 124 try: 125 from e_lib.product_log import ProductsLog 126 127 products_log = ProductsLog("llm-code-review", APP_VERSION, logger=logger) 128 products_log.post_started() 129 except Exception as e: 130 logger.warning(f"ProductsLog: {e}") 131 try: 132 cache_prompt() 133 get_langfuse() 134 econfig = EConfig(CONFIG_PATH, model=AppConfig) 135 langfuse_cleaner_cfg = econfig.get("Assistants", {}).get("LangfuseCleaner", {}) 136 if langfuse_cleaner_cfg.get("ENABLED"): 137 lf_cleaner = LangfuseCleanerAssistant( 138 project_slug=langfuse_cleaner_cfg.get("PROJECT", ""), 139 period=langfuse_cleaner_cfg.get("RETENTION_DAYS", 7), 140 logger=logger, 141 ) 142 _scheduler = AsyncIOScheduler() 143 _scheduler.add_job( 144 lf_cleaner.run, 145 CronTrigger.from_crontab(_to_cron(langfuse_cleaner_cfg["TIME"])), 146 id="langfuse_cleaner", 147 replace_existing=True, 148 ) 149 _scheduler.start() 150 logger.info(f"Ассистент '{langfuse_cleaner_cfg.get('NAME')}' включён") 151 except Exception as e: 152 logger.warning(f"Langfuse: ошибка инициализации: {e}") 153 yield 154 if _scheduler: 155 _scheduler.shutdown(wait=False) 156 logger.info("Приложение остановлено") 157 try: 158 if products_log: 159 products_log.post_shutdown() 160 except Exception as e: 161 logger.warning(f"ProductsLog: {e}") 162 163 164app = FastAPI(title="LLM Code Review", lifespan=lifespan) 165 166 167def _verify_signature(payload: bytes, signature: str | None) -> bool: 168 """Проверяет X-Gitea-Signature (HMAC-SHA256 от тела запроса).""" 169 if not config.webhook_secret: 170 return True 171 if not signature: 172 return False 173 expected = hmac.new( 174 config.webhook_secret.encode(), payload, hashlib.sha256 175 ).hexdigest() 176 return hmac.compare_digest(expected, signature) 177 178 179def _handle_pull_request(data: dict) -> None: 180 """Основная работа: достаёт данные PR и запускает ревью.""" 181 try: 182 repo_full = data.get("repository", {}).get("full_name", "") 183 if "/" not in repo_full: 184 logger.warning(f"Webhook: нет full_name репозитория: {repo_full!r}") 185 return 186 owner, repo = repo_full.split("/", 1) 187 index = data.get("number") or data.get("pull_request", {}).get("number") 188 if not index: 189 logger.warning("Webhook: нет номера PR") 190 return 191 head_sha = data.get("pull_request", {}).get("head", {}).get("sha") 192 outcome = run_review(client, config, owner, repo, int(index), head_sha) 193 if outcome.skipped: 194 logger.info(f"Ревью {owner}/{repo}#{index} пропущено: {outcome.reason}") 195 except Exception: 196 logger.exception("Ошибка при обработке webhook pull_request") 197 198 199@app.get("/health") 200def health() -> dict: 201 """Health-check для Docker healthcheck.""" 202 return {"status": "ok"} 203 204 205@app.post("/webhook") 206async def webhook(request: Request, background_tasks: BackgroundTasks): 207 """Принимает webhook от Gitea и ставит обработку в фон.""" 208 payload = await request.body() 209 210 event = request.headers.get("x-gitea-event", "") 211 signature = request.headers.get("x-gitea-signature") 212 213 if not _verify_signature(payload, signature): 214 logger.warning("Webhook: неверная подпись") 215 return JSONResponse(status_code=403, content={"status": "forbidden"}) 216 217 if event != "pull_request": 218 return {"status": "ignored", "event": event} 219 220 try: 221 data = json.loads(payload) 222 except json.JSONDecodeError as e: 223 logger.warning(f"Webhook: битый JSON: {e}") 224 return JSONResponse(status_code=400, content={"status": "bad json"}) 225 226 action = data.get("action", "") 227 if action not in PR_ACTIONS: 228 return {"status": "ignored", "action": action} 229 230 if data.get("pull_request", {}).get("draft"): 231 return {"status": "ignored", "reason": "draft"} 232 233 background_tasks.add_task(_handle_pull_request, data) 234 return {"status": "accepted", "action": action}
50class InterceptHandler(logging.Handler): 51 """ 52 Перехватчик логов из стандартного logging в loguru. 53 54 Перенаправляет все сообщения из логгеров uvicorn и fastapi 55 в форматированный вывод loguru. 56 """ 57 58 def emit(self, record): 59 """ 60 Обрабатывает и перенаправляет запись лога в loguru. 61 62 Args: 63 record: Запись лога из стандартного модуля logging 64 """ 65 try: 66 level = logger.level(record.levelname).name 67 except ValueError: 68 level = record.levelno 69 70 frame, depth = logging.currentframe(), 2 71 while frame.f_code.co_filename == logging.__file__: 72 frame = frame.f_back 73 depth += 1 74 75 logger.opt(depth=depth, exception=record.exc_info).log( 76 level, record.getMessage() 77 )
Перехватчик логов из стандартного logging в loguru.
Перенаправляет все сообщения из логгеров uvicorn и fastapi в форматированный вывод loguru.
58 def emit(self, record): 59 """ 60 Обрабатывает и перенаправляет запись лога в loguru. 61 62 Args: 63 record: Запись лога из стандартного модуля logging 64 """ 65 try: 66 level = logger.level(record.levelname).name 67 except ValueError: 68 level = record.levelno 69 70 frame, depth = logging.currentframe(), 2 71 while frame.f_code.co_filename == logging.__file__: 72 frame = frame.f_back 73 depth += 1 74 75 logger.opt(depth=depth, exception=record.exc_info).log( 76 level, record.getMessage() 77 )
Обрабатывает и перенаправляет запись лога в loguru.
Args: record: Запись лога из стандартного модуля logging
80def setup_logging(): 81 """ 82 Настраивает логирование приложения. 83 84 Перенаправляет логи uvicorn и fastapi в loguru с цветным 85 форматированным выводом в stdout. Уровень DEBUG для всех 86 сообщений приложения. 87 """ 88 logger.remove() 89 90 logger.add( 91 sys.stdout, 92 format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>", 93 level="DEBUG", 94 colorize=True, 95 ) 96 97 intercept_handler = InterceptHandler() 98 99 root_logger = logging.getLogger() 100 root_logger.handlers = [intercept_handler] 101 root_logger.setLevel(logging.INFO) 102 103 for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"]: 104 logging_logger = logging.getLogger(logger_name) 105 logging_logger.handlers = [intercept_handler] 106 logging_logger.setLevel(logging.INFO) 107 logging_logger.propagate = False
Настраивает логирование приложения.
Перенаправляет логи uvicorn и fastapi в loguru с цветным форматированным выводом в stdout. Уровень DEBUG для всех сообщений приложения.
116@asynccontextmanager 117async def lifespan(_: FastAPI): 118 """ 119 Инициализирует Langfuse и планирует фонового ассистента очистки трейсов 120 (LangfuseCleanerAssistant из e_lib) по конфигу e_lib.Config (config.yaml). 121 """ 122 global _scheduler 123 setup_logging() 124 products_log = None 125 try: 126 from e_lib.product_log import ProductsLog 127 128 products_log = ProductsLog("llm-code-review", APP_VERSION, logger=logger) 129 products_log.post_started() 130 except Exception as e: 131 logger.warning(f"ProductsLog: {e}") 132 try: 133 cache_prompt() 134 get_langfuse() 135 econfig = EConfig(CONFIG_PATH, model=AppConfig) 136 langfuse_cleaner_cfg = econfig.get("Assistants", {}).get("LangfuseCleaner", {}) 137 if langfuse_cleaner_cfg.get("ENABLED"): 138 lf_cleaner = LangfuseCleanerAssistant( 139 project_slug=langfuse_cleaner_cfg.get("PROJECT", ""), 140 period=langfuse_cleaner_cfg.get("RETENTION_DAYS", 7), 141 logger=logger, 142 ) 143 _scheduler = AsyncIOScheduler() 144 _scheduler.add_job( 145 lf_cleaner.run, 146 CronTrigger.from_crontab(_to_cron(langfuse_cleaner_cfg["TIME"])), 147 id="langfuse_cleaner", 148 replace_existing=True, 149 ) 150 _scheduler.start() 151 logger.info(f"Ассистент '{langfuse_cleaner_cfg.get('NAME')}' включён") 152 except Exception as e: 153 logger.warning(f"Langfuse: ошибка инициализации: {e}") 154 yield 155 if _scheduler: 156 _scheduler.shutdown(wait=False) 157 logger.info("Приложение остановлено") 158 try: 159 if products_log: 160 products_log.post_shutdown() 161 except Exception as e: 162 logger.warning(f"ProductsLog: {e}")
Инициализирует Langfuse и планирует фонового ассистента очистки трейсов (LangfuseCleanerAssistant из e_lib) по конфигу e_lib.Config (config.yaml).
200@app.get("/health") 201def health() -> dict: 202 """Health-check для Docker healthcheck.""" 203 return {"status": "ok"}
Health-check для Docker healthcheck.
206@app.post("/webhook") 207async def webhook(request: Request, background_tasks: BackgroundTasks): 208 """Принимает webhook от Gitea и ставит обработку в фон.""" 209 payload = await request.body() 210 211 event = request.headers.get("x-gitea-event", "") 212 signature = request.headers.get("x-gitea-signature") 213 214 if not _verify_signature(payload, signature): 215 logger.warning("Webhook: неверная подпись") 216 return JSONResponse(status_code=403, content={"status": "forbidden"}) 217 218 if event != "pull_request": 219 return {"status": "ignored", "event": event} 220 221 try: 222 data = json.loads(payload) 223 except json.JSONDecodeError as e: 224 logger.warning(f"Webhook: битый JSON: {e}") 225 return JSONResponse(status_code=400, content={"status": "bad json"}) 226 227 action = data.get("action", "") 228 if action not in PR_ACTIONS: 229 return {"status": "ignored", "action": action} 230 231 if data.get("pull_request", {}).get("draft"): 232 return {"status": "ignored", "reason": "draft"} 233 234 background_tasks.add_task(_handle_pull_request, data) 235 return {"status": "accepted", "action": action}
Принимает webhook от Gitea и ставит обработку в фон.