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:
2026-02-01 17:41:46 -06:00
parent 658ec0774d
commit e23c09f1f2
17 changed files with 1118 additions and 0 deletions

View File

@@ -158,6 +158,11 @@ GITEA_BOT_PASSWORD=REPLACE_WITH_COORDINATOR_BOT_PASSWORD
GITEA_REPO_OWNER=mosaic GITEA_REPO_OWNER=mosaic
GITEA_REPO_NAME=stack 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 # Logging & Debugging
# ====================== # ======================

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

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

View 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

View File

@@ -0,0 +1,3 @@
"""Mosaic Coordinator - Webhook receiver for Gitea issue events."""
__version__ = "0.0.1"

View 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()

View 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(),
)

View 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)

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

View File

@@ -0,0 +1 @@
"""Tests for mosaic-coordinator."""

View 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)

View 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

View 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"

View File

@@ -39,6 +39,35 @@ services:
networks: networks:
- mosaic-network - 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: volumes:
postgres_data: postgres_data:
name: mosaic-postgres-data name: mosaic-postgres-data

View 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