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
This commit is contained in:
머니페니 2026-03-20 18:15:07 +09:00
parent 9242badeff
commit 8c274b4be2
2 changed files with 203 additions and 6 deletions

View File

@ -1,13 +1,23 @@
"""galaxis-agent webhook server.""" """galaxis-agent webhook server."""
import hashlib import hashlib
import hmac import hmac
import json
import logging import logging
import os
import re
from fastapi import FastAPI, Request, HTTPException 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__) logger = logging.getLogger(__name__)
app = FastAPI(title="galaxis-agent") 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: 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]}" 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") @app.get("/health")
async def health(): async def health():
return {"status": "ok"} return {"status": "ok"}
@app.post("/webhooks/gitea") @app.post("/webhooks/gitea")
@limiter.limit("10/minute")
async def gitea_webhook(request: Request): async def gitea_webhook(request: Request):
"""Gitea webhook endpoint. Full implementation in Phase 3.""" """Gitea webhook endpoint with event parsing and task dispatch."""
import os payload_bytes = await request.body()
body = await request.body()
signature = request.headers.get("X-Gitea-Signature", "") signature = request.headers.get("X-Gitea-Signature", "")
secret = os.environ.get("GITEA_WEBHOOK_SECRET", "") 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") raise HTTPException(status_code=401, detail="Invalid signature")
logger.info("Gitea webhook received (not yet implemented)") payload = json.loads(payload_bytes)
return {"status": "received"} 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}

View File

@ -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