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:
parent
9242badeff
commit
8c274b4be2
115
agent/webapp.py
115
agent/webapp.py
@ -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}
|
||||||
|
|||||||
94
tests/test_gitea_webhook.py
Normal file
94
tests/test_gitea_webhook.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user