"""galaxis-agent webhook server.""" import hashlib import hmac import json import logging import os import re from fastapi import FastAPI, Request, HTTPException from slowapi import Limiter from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware logger = logging.getLogger(__name__) app = FastAPI(title="galaxis-agent") limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter app.add_middleware(SlowAPIMiddleware) def verify_gitea_signature(payload: bytes, signature: str, secret: str) -> bool: """Verify Gitea webhook HMAC-SHA256 signature.""" expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) def generate_thread_id(repo: str, issue_id: int) -> str: """Generate deterministic thread ID from issue.""" raw = hashlib.sha256(f"gitea-issue:{repo}:{issue_id}".encode()).hexdigest() return f"{raw[:8]}-{raw[8:12]}-{raw[12:16]}-{raw[16:20]}-{raw[20:32]}" def parse_gitea_event(event_type: str, payload: dict) -> dict: """Gitea webhook 페이로드를 파싱하여 처리 대상인지 판단한다.""" repo = payload.get("repository", {}) repo_name = repo.get("name", "") full_name = repo.get("full_name", "") repo_owner = full_name.split("/")[0] if "/" in full_name else "" base = { "should_process": False, "issue_number": 0, "repo_name": repo_name, "repo_owner": repo_owner, "message": "", "event_type": event_type, "title": "", } if event_type == "issue_comment": action = payload.get("action", "") if action != "created": return base comment_body = payload.get("comment", {}).get("body", "") issue = payload.get("issue", {}) if "@agent" not in comment_body.lower(): return base message = re.sub(r"@agent\b", "", comment_body, flags=re.IGNORECASE).strip() base.update({ "should_process": True, "issue_number": issue.get("number", 0), "message": message, "title": issue.get("title", ""), }) return base if event_type == "issues": label = payload.get("label", {}) if label.get("name") == "agent-fix": issue = payload.get("issue", {}) base.update({ "should_process": True, "issue_number": issue.get("number", 0), "message": issue.get("body", ""), "title": issue.get("title", ""), }) return base if event_type == "pull_request": action = payload.get("action", "") if action == "review_requested": pr = payload.get("pull_request", {}) base.update({ "should_process": True, "issue_number": pr.get("number", 0), "message": pr.get("body", ""), "title": pr.get("title", ""), }) return base return base @app.get("/health") async def health(): return {"status": "ok"} @app.post("/webhooks/gitea") @limiter.limit("10/minute") async def gitea_webhook(request: Request): """Gitea webhook endpoint with event parsing and task dispatch.""" payload_bytes = await request.body() signature = request.headers.get("X-Gitea-Signature", "") secret = os.environ.get("GITEA_WEBHOOK_SECRET", "") if not verify_gitea_signature(payload_bytes, signature, secret): raise HTTPException(status_code=401, detail="Invalid signature") payload = json.loads(payload_bytes) event_type = request.headers.get("X-Gitea-Event", "") event = parse_gitea_event(event_type, payload) if not event["should_process"]: return {"status": "ignored"} thread_id = generate_thread_id(event["repo_name"], event["issue_number"]) from agent.message_store import get_message_store from agent.task_queue import get_task_queue task_queue = await get_task_queue() if await task_queue.has_running_task(thread_id): store = await get_message_store() await store.push_message(thread_id, { "role": "human", "content": event["message"], }) return {"status": "queued_message", "thread_id": thread_id} task_id = await task_queue.enqueue( thread_id=thread_id, source="gitea", payload={ "issue_number": event["issue_number"], "repo_owner": event["repo_owner"], "repo_name": event["repo_name"], "message": event["message"], "title": event["title"], "event_type": event["event_type"], }, ) return {"status": "enqueued", "task_id": task_id, "thread_id": thread_id}