Files
stack/apps/coordinator/src/webhook.py
Jason Woltje e23c09f1f2 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>
2026-02-01 17:41:46 -06:00

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",
)