From 8c274b4be229e670d180f59cd9d6123d356e1685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=A8=B8=EB=8B=88=ED=8E=98=EB=8B=88?= Date: Fri, 20 Mar 2026 18:15:07 +0900 Subject: [PATCH] feat: complete Gitea webhook event parsing and task dispatch - Add parse_gitea_event() function to parse issue comments, labels, and PR review requests - Detect @agent mentions in issue comments and strip them from message - Implement rate limiting with slowapi (10 requests/minute) - Integrate with PersistentTaskQueue and MessageStore - Queue messages if task is already running for the same thread - Add 6 comprehensive tests for event parsing and signature verification --- agent/webapp.py | 115 ++++++++++++++++++++++++++++++++++-- tests/test_gitea_webhook.py | 94 +++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 tests/test_gitea_webhook.py diff --git a/agent/webapp.py b/agent/webapp.py index 3cf9a05..ebcc316 100644 --- a/agent/webapp.py +++ b/agent/webapp.py @@ -1,13 +1,23 @@ """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: @@ -22,21 +32,114 @@ def generate_thread_id(repo: str, issue_id: int) -> str: 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. Full implementation in Phase 3.""" - import os - body = await request.body() + """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(body, signature, secret): + if not verify_gitea_signature(payload_bytes, signature, secret): raise HTTPException(status_code=401, detail="Invalid signature") - logger.info("Gitea webhook received (not yet implemented)") - return {"status": "received"} + 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} diff --git a/tests/test_gitea_webhook.py b/tests/test_gitea_webhook.py new file mode 100644 index 0000000..88afee3 --- /dev/null +++ b/tests/test_gitea_webhook.py @@ -0,0 +1,94 @@ +"""Tests for Gitea webhook event parsing and signature verification.""" +import hashlib +import hmac + + +def make_signature(payload: bytes, secret: str) -> str: + """Gitea HMAC-SHA256 서명을 생성한다.""" + return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + + +def test_verify_signature(): + """Gitea webhook 서명을 검증한다.""" + from agent.webapp import verify_gitea_signature + + payload = b'{"action": "created"}' + secret = "test-secret" + sig = make_signature(payload, secret) + assert verify_gitea_signature(payload, sig, secret) is True + assert verify_gitea_signature(payload, "wrong", secret) is False + + +def test_generate_thread_id(): + """결정론적 스레드 ID를 생성한다.""" + from agent.webapp import generate_thread_id + + tid1 = generate_thread_id("galaxis-po", 42) + tid2 = generate_thread_id("galaxis-po", 42) + tid3 = generate_thread_id("galaxis-po", 43) + assert tid1 == tid2 + assert tid1 != tid3 + assert len(tid1) == 36 + assert tid1.count("-") == 4 + + +def test_parse_issue_comment_with_mention(): + """이슈 코멘트에서 @agent 멘션을 감지한다.""" + from agent.webapp import parse_gitea_event + + payload = { + "action": "created", + "comment": {"body": "@agent factor_calculator에 듀얼 모멘텀 추가해줘"}, + "issue": {"number": 42, "title": "Feature request", "body": "description"}, + "repository": {"full_name": "quant/galaxis-po", "name": "galaxis-po"}, + } + result = parse_gitea_event("issue_comment", payload) + assert result is not None + assert result["should_process"] is True + assert result["issue_number"] == 42 + assert result["repo_name"] == "galaxis-po" + assert "@agent" not in result["message"] + + +def test_parse_issue_comment_without_mention(): + """@agent 멘션이 없는 코멘트는 무시한다.""" + from agent.webapp import parse_gitea_event + + payload = { + "action": "created", + "comment": {"body": "일반 코멘트입니다"}, + "issue": {"number": 42, "title": "Bug", "body": "desc"}, + "repository": {"full_name": "quant/galaxis-po", "name": "galaxis-po"}, + } + result = parse_gitea_event("issue_comment", payload) + assert result["should_process"] is False + + +def test_parse_issue_label_agent_fix(): + """agent-fix 라벨 부착 시 작업을 트리거한다.""" + from agent.webapp import parse_gitea_event + + payload = { + "action": "label_updated", + "issue": {"number": 10, "title": "Fix login", "body": "Login fails"}, + "label": {"name": "agent-fix"}, + "repository": {"full_name": "quant/galaxis-po", "name": "galaxis-po"}, + } + result = parse_gitea_event("issues", payload) + assert result is not None + assert result["should_process"] is True + + +def test_parse_pr_review_requested(): + """PR 리뷰 요청을 감지한다.""" + from agent.webapp import parse_gitea_event + + payload = { + "action": "review_requested", + "pull_request": {"number": 5, "title": "feat: add feature", "body": "desc"}, + "repository": {"full_name": "quant/galaxis-po", "name": "galaxis-po"}, + } + result = parse_gitea_event("pull_request", payload) + assert result is not None + assert result["should_process"] is True + assert result["issue_number"] == 5