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:
@@ -158,6 +158,11 @@ GITEA_BOT_PASSWORD=REPLACE_WITH_COORDINATOR_BOT_PASSWORD
|
||||
GITEA_REPO_OWNER=mosaic
|
||||
GITEA_REPO_NAME=stack
|
||||
|
||||
# Webhook secret for coordinator (HMAC SHA256 signature verification)
|
||||
# SECURITY: Generate random secret with: openssl rand -hex 32
|
||||
# Configure in Gitea: Repository Settings → Webhooks → Add Webhook
|
||||
GITEA_WEBHOOK_SECRET=REPLACE_WITH_RANDOM_WEBHOOK_SECRET
|
||||
|
||||
# ======================
|
||||
# Logging & Debugging
|
||||
# ======================
|
||||
|
||||
42
apps/coordinator/.dockerignore
Normal file
42
apps/coordinator/.dockerignore
Normal file
@@ -0,0 +1,42 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
tests/
|
||||
|
||||
# Distribution
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
32
apps/coordinator/.gitignore
vendored
Normal file
32
apps/coordinator/.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Distribution
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
59
apps/coordinator/Dockerfile
Normal file
59
apps/coordinator/Dockerfile
Normal file
@@ -0,0 +1,59 @@
|
||||
# Multi-stage build for mosaic-coordinator
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy dependency files
|
||||
COPY pyproject.toml .
|
||||
|
||||
# Create virtual environment and install dependencies
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir hatchling && \
|
||||
pip install --no-cache-dir \
|
||||
fastapi>=0.109.0 \
|
||||
uvicorn[standard]>=0.27.0 \
|
||||
pydantic>=2.5.0 \
|
||||
pydantic-settings>=2.1.0 \
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Production stage
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy virtual environment from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Copy application code
|
||||
COPY src/ ./src/
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 coordinator && \
|
||||
chown -R coordinator:coordinator /app
|
||||
|
||||
USER coordinator
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run application
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
141
apps/coordinator/README.md
Normal file
141
apps/coordinator/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Mosaic Coordinator
|
||||
|
||||
FastAPI webhook receiver for Gitea issue events, enabling autonomous task coordination for AI agents.
|
||||
|
||||
## Overview
|
||||
|
||||
The coordinator receives webhook events from Gitea when issues are assigned, unassigned, or closed. It verifies webhook authenticity via HMAC SHA256 signature and routes events to appropriate handlers.
|
||||
|
||||
## Features
|
||||
|
||||
- HMAC SHA256 signature verification
|
||||
- Event routing (assigned, unassigned, closed)
|
||||
- Comprehensive logging
|
||||
- Health check endpoint
|
||||
- Docker containerized
|
||||
- 85%+ test coverage
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- pip or uv package manager
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src --cov-report=html
|
||||
|
||||
# Type checking
|
||||
mypy src/
|
||||
|
||||
# Linting
|
||||
ruff check src/
|
||||
```
|
||||
|
||||
### Running locally
|
||||
|
||||
```bash
|
||||
# Set environment variables
|
||||
export GITEA_WEBHOOK_SECRET="your-secret-here"
|
||||
export LOG_LEVEL="info"
|
||||
|
||||
# Run server
|
||||
uvicorn src.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /webhook/gitea
|
||||
|
||||
Receives Gitea webhook events.
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `X-Gitea-Signature`: HMAC SHA256 signature of request body
|
||||
|
||||
**Response:**
|
||||
|
||||
- `200 OK`: Event processed successfully
|
||||
- `401 Unauthorized`: Invalid or missing signature
|
||||
- `422 Unprocessable Entity`: Invalid payload
|
||||
|
||||
### GET /health
|
||||
|
||||
Health check endpoint.
|
||||
|
||||
**Response:**
|
||||
|
||||
- `200 OK`: Service is healthy
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Required | Default |
|
||||
| ---------------------- | ------------------------------------------- | -------- | ------- |
|
||||
| `GITEA_WEBHOOK_SECRET` | Secret for HMAC signature verification | Yes | - |
|
||||
| `GITEA_URL` | Gitea instance URL | Yes | - |
|
||||
| `LOG_LEVEL` | Logging level (debug, info, warning, error) | No | info |
|
||||
| `HOST` | Server host | No | 0.0.0.0 |
|
||||
| `PORT` | Server port | No | 8000 |
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Build
|
||||
docker build -t mosaic-coordinator .
|
||||
|
||||
# Run
|
||||
docker run -p 8000:8000 \
|
||||
-e GITEA_WEBHOOK_SECRET="your-secret" \
|
||||
-e GITEA_URL="https://git.mosaicstack.dev" \
|
||||
mosaic-coordinator
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest
|
||||
|
||||
# Run with coverage (requires 85%+)
|
||||
pytest --cov=src --cov-report=term-missing
|
||||
|
||||
# Run specific test file
|
||||
pytest tests/test_security.py
|
||||
|
||||
# Run with verbose output
|
||||
pytest -v
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/coordinator/
|
||||
├── src/
|
||||
│ ├── main.py # FastAPI application
|
||||
│ ├── webhook.py # Webhook endpoint handlers
|
||||
│ ├── security.py # HMAC signature verification
|
||||
│ └── config.py # Configuration management
|
||||
├── tests/
|
||||
│ ├── test_security.py
|
||||
│ ├── test_webhook.py
|
||||
│ └── conftest.py # Pytest fixtures
|
||||
├── pyproject.toml # Project metadata & dependencies
|
||||
├── Dockerfile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Related Issues
|
||||
|
||||
- #156 - Create coordinator bot user
|
||||
- #157 - Set up webhook receiver endpoint
|
||||
- #158 - Implement issue parser
|
||||
- #140 - Coordinator architecture
|
||||
49
apps/coordinator/pyproject.toml
Normal file
49
apps/coordinator/pyproject.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[project]
|
||||
name = "mosaic-coordinator"
|
||||
version = "0.0.1"
|
||||
description = "Mosaic Stack webhook receiver and task coordinator"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.109.0",
|
||||
"uvicorn[standard]>=0.27.0",
|
||||
"pydantic>=2.5.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.4.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"httpx>=0.26.0",
|
||||
"ruff>=0.1.0",
|
||||
"mypy>=1.8.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
addopts = "--cov=src --cov-report=term-missing --cov-report=html --cov-fail-under=85"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "B", "UP"]
|
||||
ignore = []
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
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",
|
||||
)
|
||||
1
apps/coordinator/tests/__init__.py
Normal file
1
apps/coordinator/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for mosaic-coordinator."""
|
||||
120
apps/coordinator/tests/conftest.py
Normal file
120
apps/coordinator/tests/conftest.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Pytest fixtures for coordinator tests."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def webhook_secret() -> str:
|
||||
"""Return a test webhook secret."""
|
||||
return "test-webhook-secret-12345"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gitea_url() -> str:
|
||||
"""Return a test Gitea URL."""
|
||||
return "https://git.mosaicstack.dev"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_assigned_payload() -> dict[str, object]:
|
||||
"""Return a sample Gitea 'assigned' issue webhook payload."""
|
||||
return {
|
||||
"action": "assigned",
|
||||
"number": 157,
|
||||
"issue": {
|
||||
"id": 157,
|
||||
"number": 157,
|
||||
"title": "[COORD-001] Set up webhook receiver endpoint",
|
||||
"state": "open",
|
||||
"assignee": {
|
||||
"id": 1,
|
||||
"login": "mosaic",
|
||||
"full_name": "Mosaic Bot",
|
||||
},
|
||||
},
|
||||
"repository": {
|
||||
"name": "stack",
|
||||
"full_name": "mosaic/stack",
|
||||
"owner": {"login": "mosaic"},
|
||||
},
|
||||
"sender": {
|
||||
"id": 2,
|
||||
"login": "admin",
|
||||
"full_name": "Admin User",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_unassigned_payload() -> dict[str, object]:
|
||||
"""Return a sample Gitea 'unassigned' issue webhook payload."""
|
||||
return {
|
||||
"action": "unassigned",
|
||||
"number": 157,
|
||||
"issue": {
|
||||
"id": 157,
|
||||
"number": 157,
|
||||
"title": "[COORD-001] Set up webhook receiver endpoint",
|
||||
"state": "open",
|
||||
"assignee": None,
|
||||
},
|
||||
"repository": {
|
||||
"name": "stack",
|
||||
"full_name": "mosaic/stack",
|
||||
"owner": {"login": "mosaic"},
|
||||
},
|
||||
"sender": {
|
||||
"id": 2,
|
||||
"login": "admin",
|
||||
"full_name": "Admin User",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_closed_payload() -> dict[str, object]:
|
||||
"""Return a sample Gitea 'closed' issue webhook payload."""
|
||||
return {
|
||||
"action": "closed",
|
||||
"number": 157,
|
||||
"issue": {
|
||||
"id": 157,
|
||||
"number": 157,
|
||||
"title": "[COORD-001] Set up webhook receiver endpoint",
|
||||
"state": "closed",
|
||||
"assignee": {
|
||||
"id": 1,
|
||||
"login": "mosaic",
|
||||
"full_name": "Mosaic Bot",
|
||||
},
|
||||
},
|
||||
"repository": {
|
||||
"name": "stack",
|
||||
"full_name": "mosaic/stack",
|
||||
"owner": {"login": "mosaic"},
|
||||
},
|
||||
"sender": {
|
||||
"id": 2,
|
||||
"login": "admin",
|
||||
"full_name": "Admin User",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(webhook_secret: str, gitea_url: str, monkeypatch: pytest.MonkeyPatch) -> TestClient:
|
||||
"""Create a FastAPI test client with test configuration."""
|
||||
# Set test environment variables
|
||||
monkeypatch.setenv("GITEA_WEBHOOK_SECRET", webhook_secret)
|
||||
monkeypatch.setenv("GITEA_URL", gitea_url)
|
||||
monkeypatch.setenv("LOG_LEVEL", "debug")
|
||||
|
||||
# Force reload of settings
|
||||
from src import config
|
||||
import importlib
|
||||
importlib.reload(config)
|
||||
|
||||
# Import app after settings are configured
|
||||
from src.main import app
|
||||
return TestClient(app)
|
||||
84
apps/coordinator/tests/test_security.py
Normal file
84
apps/coordinator/tests/test_security.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Tests for HMAC signature verification."""
|
||||
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSignatureVerification:
|
||||
"""Test suite for HMAC SHA256 signature verification."""
|
||||
|
||||
def test_verify_signature_valid(self, webhook_secret: str) -> None:
|
||||
"""Test that valid signature is accepted."""
|
||||
from src.security import verify_signature
|
||||
|
||||
payload = json.dumps({"action": "assigned", "number": 157}).encode("utf-8")
|
||||
signature = hmac.new(
|
||||
webhook_secret.encode("utf-8"), payload, "sha256"
|
||||
).hexdigest()
|
||||
|
||||
assert verify_signature(payload, signature, webhook_secret) is True
|
||||
|
||||
def test_verify_signature_invalid(self, webhook_secret: str) -> None:
|
||||
"""Test that invalid signature is rejected."""
|
||||
from src.security import verify_signature
|
||||
|
||||
payload = json.dumps({"action": "assigned", "number": 157}).encode("utf-8")
|
||||
invalid_signature = "invalid_signature_12345"
|
||||
|
||||
assert verify_signature(payload, invalid_signature, webhook_secret) is False
|
||||
|
||||
def test_verify_signature_empty_signature(self, webhook_secret: str) -> None:
|
||||
"""Test that empty signature is rejected."""
|
||||
from src.security import verify_signature
|
||||
|
||||
payload = json.dumps({"action": "assigned", "number": 157}).encode("utf-8")
|
||||
|
||||
assert verify_signature(payload, "", webhook_secret) is False
|
||||
|
||||
def test_verify_signature_wrong_secret(self, webhook_secret: str) -> None:
|
||||
"""Test that signature with wrong secret is rejected."""
|
||||
from src.security import verify_signature
|
||||
|
||||
payload = json.dumps({"action": "assigned", "number": 157}).encode("utf-8")
|
||||
wrong_secret = "wrong-secret-67890"
|
||||
signature = hmac.new(
|
||||
wrong_secret.encode("utf-8"), payload, "sha256"
|
||||
).hexdigest()
|
||||
|
||||
assert verify_signature(payload, signature, webhook_secret) is False
|
||||
|
||||
def test_verify_signature_modified_payload(self, webhook_secret: str) -> None:
|
||||
"""Test that signature fails when payload is modified."""
|
||||
from src.security import verify_signature
|
||||
|
||||
original_payload = json.dumps({"action": "assigned", "number": 157}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
signature = hmac.new(
|
||||
webhook_secret.encode("utf-8"), original_payload, "sha256"
|
||||
).hexdigest()
|
||||
|
||||
# Modify the payload
|
||||
modified_payload = json.dumps({"action": "assigned", "number": 999}).encode(
|
||||
"utf-8"
|
||||
)
|
||||
|
||||
assert verify_signature(modified_payload, signature, webhook_secret) is False
|
||||
|
||||
def test_verify_signature_timing_safe(self, webhook_secret: str) -> None:
|
||||
"""Test that signature comparison is timing-attack safe."""
|
||||
from src.security import verify_signature
|
||||
|
||||
payload = json.dumps({"action": "assigned", "number": 157}).encode("utf-8")
|
||||
signature = hmac.new(
|
||||
webhook_secret.encode("utf-8"), payload, "sha256"
|
||||
).hexdigest()
|
||||
|
||||
# Valid signature should work
|
||||
assert verify_signature(payload, signature, webhook_secret) is True
|
||||
|
||||
# Similar but wrong signature should fail (timing-safe comparison)
|
||||
wrong_signature = signature[:-1] + ("0" if signature[-1] != "0" else "1")
|
||||
assert verify_signature(payload, wrong_signature, webhook_secret) is False
|
||||
162
apps/coordinator/tests/test_webhook.py
Normal file
162
apps/coordinator/tests/test_webhook.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Tests for webhook endpoint handlers."""
|
||||
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestWebhookEndpoint:
|
||||
"""Test suite for /webhook/gitea endpoint."""
|
||||
|
||||
def _create_signature(self, payload: dict[str, object], secret: str) -> str:
|
||||
"""Create HMAC SHA256 signature for payload."""
|
||||
# Use separators to match FastAPI's JSON encoding (no spaces)
|
||||
payload_bytes = json.dumps(payload, separators=(',', ':')).encode("utf-8")
|
||||
return hmac.new(secret.encode("utf-8"), payload_bytes, "sha256").hexdigest()
|
||||
|
||||
def test_webhook_missing_signature(
|
||||
self, client: TestClient, sample_assigned_payload: dict[str, object]
|
||||
) -> None:
|
||||
"""Test that webhook without signature returns 401."""
|
||||
response = client.post("/webhook/gitea", json=sample_assigned_payload)
|
||||
assert response.status_code == 401
|
||||
assert "Invalid or missing signature" in response.json()["detail"]
|
||||
|
||||
def test_webhook_invalid_signature(
|
||||
self, client: TestClient, sample_assigned_payload: dict[str, object]
|
||||
) -> None:
|
||||
"""Test that webhook with invalid signature returns 401."""
|
||||
headers = {"X-Gitea-Signature": "invalid_signature"}
|
||||
response = client.post(
|
||||
"/webhook/gitea", json=sample_assigned_payload, headers=headers
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert "Invalid or missing signature" in response.json()["detail"]
|
||||
|
||||
def test_webhook_assigned_event(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_assigned_payload: dict[str, object],
|
||||
webhook_secret: str,
|
||||
) -> None:
|
||||
"""Test that assigned event is processed successfully."""
|
||||
signature = self._create_signature(sample_assigned_payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
response = client.post(
|
||||
"/webhook/gitea", json=sample_assigned_payload, headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
assert response.json()["action"] == "assigned"
|
||||
assert response.json()["issue_number"] == 157
|
||||
|
||||
def test_webhook_unassigned_event(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_unassigned_payload: dict[str, object],
|
||||
webhook_secret: str,
|
||||
) -> None:
|
||||
"""Test that unassigned event is processed successfully."""
|
||||
signature = self._create_signature(sample_unassigned_payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
response = client.post(
|
||||
"/webhook/gitea", json=sample_unassigned_payload, headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
assert response.json()["action"] == "unassigned"
|
||||
assert response.json()["issue_number"] == 157
|
||||
|
||||
def test_webhook_closed_event(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_closed_payload: dict[str, object],
|
||||
webhook_secret: str,
|
||||
) -> None:
|
||||
"""Test that closed event is processed successfully."""
|
||||
signature = self._create_signature(sample_closed_payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
response = client.post(
|
||||
"/webhook/gitea", json=sample_closed_payload, headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "success"
|
||||
assert response.json()["action"] == "closed"
|
||||
assert response.json()["issue_number"] == 157
|
||||
|
||||
def test_webhook_unsupported_action(
|
||||
self, client: TestClient, webhook_secret: str
|
||||
) -> None:
|
||||
"""Test that unsupported actions are handled gracefully."""
|
||||
payload = {
|
||||
"action": "opened", # Not a supported action
|
||||
"number": 157,
|
||||
"issue": {"id": 157, "number": 157, "title": "Test"},
|
||||
}
|
||||
signature = self._create_signature(payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
response = client.post("/webhook/gitea", json=payload, headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ignored"
|
||||
assert response.json()["action"] == "opened"
|
||||
|
||||
def test_webhook_malformed_payload(
|
||||
self, client: TestClient, webhook_secret: str
|
||||
) -> None:
|
||||
"""Test that malformed payload returns 422."""
|
||||
payload = {"invalid": "payload"} # Missing required fields
|
||||
signature = self._create_signature(payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
response = client.post("/webhook/gitea", json=payload, headers=headers)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_webhook_logs_events(
|
||||
self,
|
||||
client: TestClient,
|
||||
sample_assigned_payload: dict[str, object],
|
||||
webhook_secret: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test that webhook events are logged."""
|
||||
signature = self._create_signature(sample_assigned_payload, webhook_secret)
|
||||
headers = {"X-Gitea-Signature": signature}
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
response = client.post(
|
||||
"/webhook/gitea", json=sample_assigned_payload, headers=headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Check that event was logged
|
||||
assert any("Webhook event received" in record.message for record in caplog.records)
|
||||
assert any("action=assigned" in record.message for record in caplog.records)
|
||||
assert any("issue_number=157" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Test suite for /health endpoint."""
|
||||
|
||||
def test_health_check_returns_200(self, client: TestClient) -> None:
|
||||
"""Test that health check endpoint returns 200 OK."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "healthy"
|
||||
|
||||
def test_health_check_includes_service_name(self, client: TestClient) -> None:
|
||||
"""Test that health check includes service name."""
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert "service" in response.json()
|
||||
assert response.json()["service"] == "mosaic-coordinator"
|
||||
@@ -39,6 +39,35 @@ services:
|
||||
networks:
|
||||
- mosaic-network
|
||||
|
||||
coordinator:
|
||||
build:
|
||||
context: ../apps/coordinator
|
||||
dockerfile: Dockerfile
|
||||
container_name: mosaic-coordinator
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GITEA_WEBHOOK_SECRET: ${GITEA_WEBHOOK_SECRET}
|
||||
GITEA_URL: ${GITEA_URL:-https://git.mosaicstack.dev}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8000
|
||||
ports:
|
||||
- "8000:8000"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- mosaic-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
name: mosaic-postgres-data
|
||||
|
||||
56
docs/scratchpads/157-webhook-receiver.md
Normal file
56
docs/scratchpads/157-webhook-receiver.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Issue #157: Set up webhook receiver endpoint
|
||||
|
||||
## Objective
|
||||
|
||||
Implement FastAPI webhook receiver that handles Gitea issue assignment events with HMAC SHA256 signature verification.
|
||||
|
||||
## Approach
|
||||
|
||||
1. Create new Python service: `apps/coordinator/` (FastAPI app)
|
||||
2. Structure:
|
||||
- `src/main.py` - FastAPI application entry point
|
||||
- `src/webhook.py` - Webhook endpoint handlers
|
||||
- `src/security.py` - HMAC signature verification
|
||||
- `src/config.py` - Configuration management
|
||||
- `tests/` - Unit and integration tests
|
||||
3. Follow TDD: Write tests first, then implementation
|
||||
4. Add Docker support with health checks
|
||||
5. Update docker-compose for coordinator service
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Create directory structure
|
||||
- [x] Write tests for HMAC signature verification (RED)
|
||||
- [x] Implement signature verification (GREEN)
|
||||
- [x] Write tests for webhook endpoint (RED)
|
||||
- [x] Implement webhook endpoint (GREEN)
|
||||
- [x] Write tests for event routing (RED)
|
||||
- [x] Implement event routing (GREEN)
|
||||
- [x] Add health check endpoint
|
||||
- [x] Create Dockerfile
|
||||
- [x] Update docker-compose.yml
|
||||
- [x] Run quality gates (build, lint, test, coverage)
|
||||
- [x] Update .env.example with webhook secret
|
||||
- [ ] Commit implementation
|
||||
- [ ] Update issue status
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for `security.verify_signature()`
|
||||
- Unit tests for each event handler (assigned, unassigned, closed)
|
||||
- Integration test with mock Gitea webhook payload
|
||||
- Security test: Invalid signature returns 401
|
||||
- Health check test
|
||||
|
||||
## Notes
|
||||
|
||||
- Python service alongside NestJS apps (polyglot monorepo)
|
||||
- Use pytest for testing framework
|
||||
- Use pydantic for request validation
|
||||
- Minimum 85% coverage required
|
||||
- Need to add webhook secret to .env.example
|
||||
|
||||
## Token Tracking
|
||||
|
||||
- Estimated: 52,000 tokens
|
||||
- Actual: TBD
|
||||
Reference in New Issue
Block a user