feat(#157): Set up webhook receiver endpoint
Implement FastAPI webhook receiver for Gitea issue assignment events with HMAC SHA256 signature verification and event routing. Implementation details: - FastAPI application with /webhook/gitea POST endpoint - HMAC SHA256 signature verification in security.py - Event routing for assigned, unassigned, closed actions - Comprehensive logging for all webhook events - Health check endpoint at /health - Docker containerization with health checks - 91% test coverage (exceeds 85% requirement) TDD workflow followed: - Wrote 16 tests first (RED phase) - Implemented features to pass tests (GREEN phase) - All tests passing with 91% coverage - Type checking with mypy: success - Linting with ruff: success Files created: - apps/coordinator/src/main.py - FastAPI application - apps/coordinator/src/webhook.py - Webhook handlers - apps/coordinator/src/security.py - HMAC verification - apps/coordinator/src/config.py - Configuration management - apps/coordinator/tests/ - Comprehensive test suite - apps/coordinator/Dockerfile - Production container - apps/coordinator/pyproject.toml - Python project config Configuration: - Updated .env.example with GITEA_WEBHOOK_SECRET - Updated docker-compose.yml with coordinator service Testing: - 16 unit and integration tests - Security tests for signature verification - Event handler tests for all supported actions - Health check endpoint tests - All tests passing with 91% coverage This unblocks issue #158 (issue parser). Fixes #157 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
3
apps/coordinator/src/__init__.py
Normal file
3
apps/coordinator/src/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Mosaic Coordinator - Webhook receiver for Gitea issue events."""
|
||||
|
||||
__version__ = "0.0.1"
|
||||
34
apps/coordinator/src/config.py
Normal file
34
apps/coordinator/src/config.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Configuration management for mosaic-coordinator."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Gitea Configuration
|
||||
gitea_webhook_secret: str
|
||||
gitea_url: str = "https://git.mosaicstack.dev"
|
||||
|
||||
# Server Configuration
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
# Logging
|
||||
log_level: str = "info"
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get settings instance (lazy loaded)."""
|
||||
return Settings() # type: ignore[call-arg]
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = get_settings()
|
||||
89
apps/coordinator/src/main.py
Normal file
89
apps/coordinator/src/main.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""FastAPI application for mosaic-coordinator webhook receiver."""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .config import settings
|
||||
from .webhook import router as webhook_router
|
||||
|
||||
|
||||
# Configure logging
|
||||
def setup_logging() -> None:
|
||||
"""Configure logging for the application."""
|
||||
log_level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
# Setup logging on module import
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""
|
||||
Application lifespan manager.
|
||||
|
||||
Handles startup and shutdown logic.
|
||||
"""
|
||||
# Startup
|
||||
logger.info("Starting mosaic-coordinator webhook receiver")
|
||||
logger.info(f"Gitea URL: {settings.gitea_url}")
|
||||
logger.info(f"Log level: {settings.log_level}")
|
||||
logger.info(f"Server: {settings.host}:{settings.port}")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down mosaic-coordinator webhook receiver")
|
||||
|
||||
|
||||
# Create FastAPI application
|
||||
app = FastAPI(
|
||||
title="Mosaic Coordinator",
|
||||
description="Webhook receiver for Gitea issue events",
|
||||
version="0.0.1",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response model."""
|
||||
|
||||
status: str
|
||||
service: str
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
async def health_check() -> HealthResponse:
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns:
|
||||
HealthResponse indicating service is healthy
|
||||
"""
|
||||
return HealthResponse(status="healthy", service="mosaic-coordinator")
|
||||
|
||||
|
||||
# Include webhook router
|
||||
app.include_router(webhook_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"src.main:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
reload=True,
|
||||
log_level=settings.log_level.lower(),
|
||||
)
|
||||
35
apps/coordinator/src/security.py
Normal file
35
apps/coordinator/src/security.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Security utilities for webhook signature verification."""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
|
||||
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
"""
|
||||
Verify HMAC SHA256 signature of webhook payload.
|
||||
|
||||
Args:
|
||||
payload: Raw request body as bytes
|
||||
signature: Signature from X-Gitea-Signature header
|
||||
secret: Webhook secret configured in Gitea
|
||||
|
||||
Returns:
|
||||
True if signature is valid, False otherwise
|
||||
|
||||
Example:
|
||||
>>> payload = b'{"action": "assigned"}'
|
||||
>>> secret = "my-webhook-secret"
|
||||
>>> sig = hmac.new(secret.encode(), payload, "sha256").hexdigest()
|
||||
>>> verify_signature(payload, sig, secret)
|
||||
True
|
||||
"""
|
||||
if not signature:
|
||||
return False
|
||||
|
||||
# Compute expected signature
|
||||
expected_signature = hmac.new(
|
||||
secret.encode("utf-8"), payload, hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# Use timing-safe comparison to prevent timing attacks
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
177
apps/coordinator/src/webhook.py
Normal file
177
apps/coordinator/src/webhook.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Webhook endpoint handlers for Gitea events."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .config import settings
|
||||
from .security import verify_signature
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class WebhookResponse(BaseModel):
|
||||
"""Response model for webhook endpoint."""
|
||||
|
||||
status: str = Field(..., description="Status of webhook processing")
|
||||
action: str = Field(..., description="Action type from webhook")
|
||||
issue_number: int | None = Field(None, description="Issue number if applicable")
|
||||
message: str | None = Field(None, description="Additional message")
|
||||
|
||||
|
||||
class GiteaWebhookPayload(BaseModel):
|
||||
"""Model for Gitea webhook payload."""
|
||||
|
||||
action: str = Field(..., description="Action type (assigned, unassigned, closed, etc.)")
|
||||
number: int = Field(..., description="Issue or PR number")
|
||||
issue: dict[str, Any] | None = Field(None, description="Issue details")
|
||||
repository: dict[str, Any] | None = Field(None, description="Repository details")
|
||||
sender: dict[str, Any] | None = Field(None, description="User who triggered event")
|
||||
|
||||
|
||||
@router.post("/webhook/gitea", response_model=WebhookResponse)
|
||||
async def handle_gitea_webhook(
|
||||
request: Request,
|
||||
payload: GiteaWebhookPayload,
|
||||
x_gitea_signature: str | None = Header(None, alias="X-Gitea-Signature"),
|
||||
) -> WebhookResponse:
|
||||
"""
|
||||
Handle Gitea webhook events.
|
||||
|
||||
Verifies HMAC SHA256 signature and routes events to appropriate handlers.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
payload: Parsed webhook payload
|
||||
x_gitea_signature: HMAC signature from Gitea
|
||||
|
||||
Returns:
|
||||
WebhookResponse indicating success or failure
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if signature is invalid or missing
|
||||
"""
|
||||
# Get raw request body for signature verification
|
||||
body = await request.body()
|
||||
|
||||
# Verify signature
|
||||
if not x_gitea_signature or not verify_signature(
|
||||
body, x_gitea_signature, settings.gitea_webhook_secret
|
||||
):
|
||||
logger.warning(
|
||||
"Webhook received with invalid or missing signature",
|
||||
extra={"action": payload.action, "issue_number": payload.number},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid or missing signature")
|
||||
|
||||
# Log the event
|
||||
logger.info(
|
||||
f"Webhook event received: action={payload.action}, issue_number={payload.number}",
|
||||
extra={
|
||||
"action": payload.action,
|
||||
"issue_number": payload.number,
|
||||
"repository": payload.repository.get("full_name") if payload.repository else None,
|
||||
},
|
||||
)
|
||||
|
||||
# Route to appropriate handler based on action
|
||||
if payload.action == "assigned":
|
||||
return await handle_assigned_event(payload)
|
||||
elif payload.action == "unassigned":
|
||||
return await handle_unassigned_event(payload)
|
||||
elif payload.action == "closed":
|
||||
return await handle_closed_event(payload)
|
||||
else:
|
||||
# Ignore unsupported actions
|
||||
logger.debug(f"Ignoring unsupported action: {payload.action}")
|
||||
return WebhookResponse(
|
||||
status="ignored",
|
||||
action=payload.action,
|
||||
issue_number=payload.number,
|
||||
message=f"Action '{payload.action}' is not supported",
|
||||
)
|
||||
|
||||
|
||||
async def handle_assigned_event(payload: GiteaWebhookPayload) -> WebhookResponse:
|
||||
"""
|
||||
Handle issue assigned event.
|
||||
|
||||
Args:
|
||||
payload: Webhook payload
|
||||
|
||||
Returns:
|
||||
WebhookResponse indicating success
|
||||
"""
|
||||
logger.info(
|
||||
f"Issue #{payload.number} assigned",
|
||||
extra={
|
||||
"issue_number": payload.number,
|
||||
"assignee": payload.issue.get("assignee", {}).get("login") if payload.issue else None,
|
||||
},
|
||||
)
|
||||
|
||||
# TODO: Trigger issue parser and context estimator (issue #158)
|
||||
# For now, just log and return success
|
||||
|
||||
return WebhookResponse(
|
||||
status="success",
|
||||
action="assigned",
|
||||
issue_number=payload.number,
|
||||
message=f"Issue #{payload.number} assigned event processed",
|
||||
)
|
||||
|
||||
|
||||
async def handle_unassigned_event(payload: GiteaWebhookPayload) -> WebhookResponse:
|
||||
"""
|
||||
Handle issue unassigned event.
|
||||
|
||||
Args:
|
||||
payload: Webhook payload
|
||||
|
||||
Returns:
|
||||
WebhookResponse indicating success
|
||||
"""
|
||||
logger.info(
|
||||
f"Issue #{payload.number} unassigned",
|
||||
extra={"issue_number": payload.number},
|
||||
)
|
||||
|
||||
# TODO: Update coordinator state (issue #159+)
|
||||
# For now, just log and return success
|
||||
|
||||
return WebhookResponse(
|
||||
status="success",
|
||||
action="unassigned",
|
||||
issue_number=payload.number,
|
||||
message=f"Issue #{payload.number} unassigned event processed",
|
||||
)
|
||||
|
||||
|
||||
async def handle_closed_event(payload: GiteaWebhookPayload) -> WebhookResponse:
|
||||
"""
|
||||
Handle issue closed event.
|
||||
|
||||
Args:
|
||||
payload: Webhook payload
|
||||
|
||||
Returns:
|
||||
WebhookResponse indicating success
|
||||
"""
|
||||
logger.info(
|
||||
f"Issue #{payload.number} closed",
|
||||
extra={"issue_number": payload.number},
|
||||
)
|
||||
|
||||
# TODO: Update coordinator state and cleanup (issue #159+)
|
||||
# For now, just log and return success
|
||||
|
||||
return WebhookResponse(
|
||||
status="success",
|
||||
action="closed",
|
||||
issue_number=payload.number,
|
||||
message=f"Issue #{payload.number} closed event processed",
|
||||
)
|
||||
Reference in New Issue
Block a user