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>
178 lines
5.3 KiB
Python
178 lines
5.3 KiB
Python
"""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",
|
|
)
|