"""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 slowapi import Limiter from slowapi.util import get_remote_address from .config import settings from .security import verify_signature logger = logging.getLogger(__name__) router = APIRouter() # Initialize limiter for this module limiter = Limiter(key_func=get_remote_address) 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) @limiter.limit("100/minute") # Per-IP rate limit: 100 requests per minute 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", )