from __future__ import annotations import asyncio import logging from fastapi import APIRouter, Request from fastapi.responses import PlainTextResponse from yuxi.channel.extensions.gitlab.config import GitLabConfigAdapter from yuxi.channel.extensions.gitlab.monitor import GitLabMonitor from yuxi.channel.extensions.gitlab.webhook import GitLabWebhookHandler logger = logging.getLogger(__name__) router = APIRouter(prefix="/webhook/gitlab", tags=["gitlab"]) _current_gateway: GitLabGateway | None = None _target_queue: asyncio.Queue | None = None def set_target_queue(queue: asyncio.Queue) -> None: global _target_queue _target_queue = queue @router.post("/events") async def gitlab_webhook_events(request: Request): if _current_gateway is None: return PlainTextResponse("", status_code=503) gateway = _current_gateway if gateway._queue is None or gateway._webhook_handler is None: return PlainTextResponse("", status_code=503) try: body = await request.body() except Exception: logger.exception("GitLab webhook: read body failed") return PlainTextResponse("", status_code=400) headers = {k.lower(): v for k, v in request.headers.items()} result = await gateway._webhook_handler.handle_webhook(body, headers) status = result.get("status", "error") if status == "unauthorized": return PlainTextResponse("", status_code=401) if status == "invalid_payload": return PlainTextResponse("", status_code=400) if status in ("duplicate", "self_message"): return PlainTextResponse("ok") if status == "ok" and gateway._monitor and _target_queue: try: unified = gateway._monitor.parse_webhook_to_unified( result.get("payload", {}), result.get("account_id", "default"), result.get("event_type", ""), ) if unified: _target_queue.put_nowait(unified) except asyncio.QueueFull: logger.warning("GitLab target queue full, dropping message") return PlainTextResponse("ok") class GitLabGateway: def __init__(self): self._running = False self._tasks: list[asyncio.Task] = [] self._account: dict = {} self._queue: asyncio.Queue | None = None self._webhook_handler: GitLabWebhookHandler | None = None self._monitor: GitLabMonitor | None = None async def start(self, ctx) -> object: global _current_gateway config = getattr(ctx, "config", {}) if ctx else {} account_id = getattr(ctx, "account_id", "default") config_adapter = GitLabConfigAdapter() config_adapter.list_account_ids(config) self._account = await config_adapter.resolve_account(account_id) if not config_adapter.is_configured(self._account): logger.warning("gitlab account %s not configured, skipping start", account_id) return {"running": False, "reason": "not-configured"} probe_result = await self._probe_connection(self._account) if not probe_result.get("healthy"): logger.warning("gitlab connection probe failed for account %s: %s", account_id, probe_result.get("reason")) return {"running": False, "reason": f"probe-failed: {probe_result.get('reason', 'unknown')}"} self._queue = asyncio.Queue(maxsize=1000) self._webhook_handler = GitLabWebhookHandler( webhook_token=self._account.get("webhook_token", ""), bot_username=self._account.get("bot_username", ""), signing_secret=self._account.get("webhook_signing_secret", ""), ) self._monitor = GitLabMonitor() await self._register_webhook_if_needed(self._account) self._running = True _current_gateway = self logger.info( "gitlab gateway started for project %s (id=%s) on %s", self._account.get("project_path"), self._account.get("project_id"), self._account.get("base_url"), ) return { "running": True, "account_id": account_id, "project_id": self._account.get("project_id"), "base_url": self._account.get("base_url"), } async def stop(self, ctx) -> None: global _current_gateway self._running = False for task in self._tasks: task.cancel() self._tasks.clear() self._queue = None self._webhook_handler = None self._monitor = None _current_gateway = None logger.info("gitlab gateway stopped") @staticmethod async def _probe_connection(account: dict) -> dict: try: from yuxi.channel.extensions.gitlab.api import AsyncGitLabClient client = AsyncGitLabClient( base_url=account.get("base_url", "https://gitlab.com"), private_token=account.get("private_token", ""), ssl_verify=account.get("ssl_verify", True), ) version = await client.probe() if version: return {"healthy": True, "version": version.get("version", "")} return {"healthy": False, "reason": "version probe returned empty"} except Exception as e: return {"healthy": False, "reason": str(e)} @staticmethod async def _register_webhook_if_needed(account: dict) -> None: project_id = account.get("project_id", 0) webhook_path = account.get("webhook_path", "/api/webhook/gitlab") if not project_id: return try: from yuxi.channel.extensions.gitlab.api import AsyncGitLabClient client = AsyncGitLabClient( base_url=account.get("base_url", "https://gitlab.com"), private_token=account.get("private_token", ""), ssl_verify=account.get("ssl_verify", True), ) hooks = await client.list_hooks(project_id) existing = [h for h in hooks if getattr(h, "url", "").endswith(webhook_path)] if not existing: logger.info("gitlab webhook not found for project %s, creating...", project_id) hook_data = { "url": f"https://{account.get('webhook_domain', 'yuxi.example.com')}{webhook_path}", "push_events": True, "issues_events": account.get("listen_issues", True), "merge_requests_events": account.get("listen_merge_requests", True), "note_events": account.get("listen_notes", True), "pipeline_events": account.get("listen_pipelines", True), "deployment_events": account.get("listen_deployments", True), "job_events": True, "tag_push_events": True, "releases_events": True, "token": account.get("webhook_token", ""), "enable_ssl_verification": True, } await client.create_hook(project_id, hook_data) logger.info("gitlab webhook created for project %s", project_id) else: logger.info("gitlab webhook already exists for project %s", project_id) except Exception: logger.exception("gitlab webhook registration failed, manual setup may be required")