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