galaxis-agent/agent/webapp.py
머니페니 d35efae12e feat: add health check endpoints for Gitea, Discord, and queue
Adds three new monitoring endpoints:
- GET /health/gitea — Verifies Gitea API connectivity
- GET /health/discord — Reports Discord bot connection status
- GET /health/queue — Returns pending task count

All endpoints return JSON with status field. The Gitea endpoint
includes the API status code on success or error message on failure.
Discord endpoint returns "not_configured", "connecting", or "ok" with
bot username. Queue endpoint includes pending_tasks count.

Tests use mock lifespan to avoid initializing task queue, message
store, dispatcher, and discord handler during testing.
2026-03-20 18:24:07 +09:00

218 lines
7.0 KiB
Python

"""galaxis-agent webhook server."""
import asyncio
import hashlib
import hmac
import json
import logging
import os
import re
from contextlib import asynccontextmanager
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__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""애플리케이션 시작/종료 시 리소스를 관리한다."""
from agent.task_queue import get_task_queue
from agent.message_store import get_message_store
from agent.dispatcher import Dispatcher
from agent.integrations.discord_handler import DiscordHandler
task_queue = await get_task_queue()
message_store = await get_message_store()
dispatcher = Dispatcher(task_queue=task_queue)
await dispatcher.start()
app.state.dispatcher = dispatcher
discord_token = os.environ.get("DISCORD_TOKEN", "")
discord_handler = None
if discord_token:
discord_handler = DiscordHandler()
discord_task = asyncio.create_task(discord_handler.start(discord_token))
app.state.discord_handler = discord_handler
logger.info("Discord bot starting...")
yield
await dispatcher.stop()
if discord_handler:
await discord_handler.close()
await task_queue.close()
await message_store.close()
logger.info("Application shutdown complete")
app = FastAPI(title="galaxis-agent", lifespan=lifespan)
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.get("/health/gitea")
async def health_gitea():
"""Gitea 연결 상태를 확인한다."""
try:
from agent.utils.gitea_client import get_gitea_client
client = get_gitea_client()
resp = await client._client.get("/settings/api")
return {"status": "ok", "gitea_status_code": resp.status_code}
except Exception as e:
return {"status": "error", "error": str(e)}
@app.get("/health/discord")
async def health_discord(request: Request):
"""Discord 봇 연결 상태를 확인한다."""
handler = getattr(request.app.state, "discord_handler", None)
if not handler:
return {"status": "not_configured"}
bot = handler.bot
if bot.is_ready():
return {"status": "ok", "user": str(bot.user)}
return {"status": "connecting"}
@app.get("/health/queue")
async def health_queue():
"""작업 큐 상태를 반환한다."""
from agent.task_queue import get_task_queue
task_queue = await get_task_queue()
pending = await task_queue.get_pending()
return {
"status": "ok",
"pending_tasks": len(pending),
}
@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}