T44: ActivityDefinition markdown file parser (definition_parser.py)
- Scans activity-definitions/*.md and ACTIVITY_DEFINITION_DIRS paths
- Parses YAML frontmatter + fenced rule/instruction blocks
- Raises ParseError on any malformed file — never silently skips
T45: ActivityDefinition sync command
- Migration 0006: adds rules_json/instructions_json JSONB columns
- sync_activity_definitions.py + make sync-activity-definitions
- Called at worker startup before schedule sync
T46: Rule/instruction pipeline wired into RunActivityWorkflow
- New evaluate_rules and emit_tasks Temporal activities
- Workflow passes event_envelope_json to enable rule evaluation
- EventRouter now passes full envelope JSON as 4th workflow arg
- IssueSink.emit() writes task_spawn_log rows per task
T47: ScheduledTriggerConfig model (one-off future datetime trigger)
T48: One-off Temporal Schedule support
- Fixed timezone_name → time_zone_name (was causing all schedule tests to fail)
- Added ScheduleCalendarSpec-based one-off schedule with remaining_actions=1
- cancel_scheduled() for admin cancellation
- Fixed backfill() call to use *args unpacking (not list wrapper)
- Fixed ScheduleAlreadyRunningError catch in upsert_schedule
- sync_schedules now handles ScheduledTriggerConfig definitions
T49: Webhook receiver
- POST /webhooks/gitea — HMAC-SHA256 via X-Gitea-Signature-256
- POST /webhooks/github — HMAC-SHA256 via X-Hub-Signature-256
- Normalisers: repo.created, push, issue.closed → EventEnvelope
- Publishes to NATS activity.{type} subject after registry validation
- Mounted in api.py at /webhooks prefix
T50: Gitea event type definitions
- gitea.repo.created.md, gitea.push.md, gitea.issue.closed.md
- Each includes normaliser field mapping in Consumer Notes
Tests: 18 passed, 1 skipped (integration). Fixed embedded Temporal
server visibility latency in test_upsert_schedule_creates_schedule.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
246 lines
8.6 KiB
Python
246 lines
8.6 KiB
Python
"""Webhook receiver for Gitea and GitHub events (T49).
|
|
|
|
Mounted at /webhooks/{source} in api.py.
|
|
|
|
Validates HMAC signatures, normalises payloads to EventEnvelope, validates
|
|
against the event type registry, and publishes to NATS subject activity.events.
|
|
|
|
Config:
|
|
WEBHOOK_SECRET_GITEA — shared secret for Gitea HMAC-SHA256
|
|
WEBHOOK_SECRET_GITHUB — shared secret for GitHub HMAC-SHA256
|
|
NATS_URL — NATS server URL (default: nats://localhost:4222)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import logging
|
|
import os
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Header, HTTPException, Request
|
|
|
|
from activity_core.models import EventEnvelope
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WEBHOOK_SECRET_GITEA = os.environ.get("WEBHOOK_SECRET_GITEA", "")
|
|
WEBHOOK_SECRET_GITHUB = os.environ.get("WEBHOOK_SECRET_GITHUB", "")
|
|
NATS_URL = os.environ.get("NATS_URL", "nats://localhost:4222")
|
|
|
|
router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
|
|
|
|
|
def _verify_hmac(body: bytes, signature: str, secret: str) -> bool:
|
|
"""Return True if signature matches HMAC-SHA256(body, secret)."""
|
|
if not secret:
|
|
return False
|
|
expected = "sha256=" + hmac.new(
|
|
secret.encode(), body, hashlib.sha256
|
|
).hexdigest()
|
|
return hmac.compare_digest(expected.encode(), signature.encode())
|
|
|
|
|
|
# ── Gitea normalisers ─────────────────────────────────────────────────────────
|
|
|
|
def _gitea_repo_created(payload: dict, now: datetime) -> EventEnvelope | None:
|
|
"""Gitea repository event (action=created) → gitea.repo.created."""
|
|
if payload.get("action") != "created":
|
|
return None
|
|
repo = payload.get("repository") or payload
|
|
name = repo.get("full_name", "")
|
|
slug = repo.get("name", name.split("/")[-1] if "/" in name else name)
|
|
return EventEnvelope(
|
|
id=str(uuid.uuid4()),
|
|
type="gitea.repo.created",
|
|
timestamp=now,
|
|
publisher="gitea/webhook",
|
|
attributes={
|
|
"repo_full_name": name,
|
|
"repo_slug": slug,
|
|
"owner": (repo.get("owner") or {}).get("login", ""),
|
|
"html_url": repo.get("html_url", ""),
|
|
"created_at": repo.get("created", now.isoformat()),
|
|
},
|
|
)
|
|
|
|
|
|
def _gitea_push(payload: dict, now: datetime) -> EventEnvelope | None:
|
|
"""Gitea push event → gitea.push."""
|
|
repo = payload.get("repository") or {}
|
|
ref = payload.get("ref", "")
|
|
branch = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
|
|
commits = payload.get("commits") or []
|
|
return EventEnvelope(
|
|
id=str(uuid.uuid4()),
|
|
type="gitea.push",
|
|
timestamp=now,
|
|
publisher="gitea/webhook",
|
|
attributes={
|
|
"repo_full_name": repo.get("full_name", ""),
|
|
"branch": branch,
|
|
"pusher": (payload.get("pusher") or {}).get("login", ""),
|
|
"commits_count": len(commits),
|
|
"compare_url": payload.get("compare", ""),
|
|
"pushed_at": now.isoformat(),
|
|
},
|
|
)
|
|
|
|
|
|
def _gitea_issue_closed(payload: dict, now: datetime) -> EventEnvelope | None:
|
|
"""Gitea issues event (action=closed) → gitea.issue.closed."""
|
|
if payload.get("action") != "closed":
|
|
return None
|
|
issue = payload.get("issue") or {}
|
|
repo = payload.get("repository") or {}
|
|
return EventEnvelope(
|
|
id=str(uuid.uuid4()),
|
|
type="gitea.issue.closed",
|
|
timestamp=now,
|
|
publisher="gitea/webhook",
|
|
attributes={
|
|
"repo_full_name": repo.get("full_name", ""),
|
|
"issue_number": issue.get("number", 0),
|
|
"issue_title": issue.get("title", ""),
|
|
"closer": (payload.get("sender") or {}).get("login", ""),
|
|
"closed_at": now.isoformat(),
|
|
},
|
|
)
|
|
|
|
|
|
_GITEA_NORMALISERS: dict[str, Any] = {
|
|
"repository": _gitea_repo_created,
|
|
"push": _gitea_push,
|
|
"issues": _gitea_issue_closed,
|
|
}
|
|
|
|
# ── GitHub normalisers ────────────────────────────────────────────────────────
|
|
|
|
def _github_push(payload: dict, now: datetime) -> EventEnvelope | None:
|
|
"""GitHub push event → gitea.push (reuse same event type)."""
|
|
repo = payload.get("repository") or {}
|
|
ref = payload.get("ref", "")
|
|
branch = ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
|
|
commits = payload.get("commits") or []
|
|
return EventEnvelope(
|
|
id=str(uuid.uuid4()),
|
|
type="gitea.push",
|
|
timestamp=now,
|
|
publisher="github/webhook",
|
|
attributes={
|
|
"repo_full_name": repo.get("full_name", ""),
|
|
"branch": branch,
|
|
"pusher": (payload.get("pusher") or {}).get("name", ""),
|
|
"commits_count": len(commits),
|
|
"compare_url": payload.get("compare", ""),
|
|
"pushed_at": now.isoformat(),
|
|
},
|
|
)
|
|
|
|
|
|
_GITHUB_NORMALISERS: dict[str, Any] = {
|
|
"push": _github_push,
|
|
}
|
|
|
|
|
|
async def _publish_to_nats(envelope: EventEnvelope) -> None:
|
|
"""Publish the normalised envelope to NATS subject activity.events."""
|
|
try:
|
|
import nats
|
|
nc = await nats.connect(NATS_URL)
|
|
subject = f"activity.{envelope.type}"
|
|
await nc.publish(subject, envelope.model_dump_json().encode())
|
|
await nc.flush()
|
|
await nc.close()
|
|
logger.info("published %r to NATS subject %r", envelope.id, subject)
|
|
except Exception as exc:
|
|
logger.error("failed to publish %r to NATS: %s", envelope.id, exc)
|
|
raise
|
|
|
|
|
|
# ── Route handlers ────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/gitea")
|
|
async def receive_gitea(
|
|
request: Request,
|
|
x_gitea_event: str = Header(default=""),
|
|
x_gitea_signature_256: str = Header(default="", alias="X-Gitea-Signature-256"),
|
|
) -> dict[str, str]:
|
|
body = await request.body()
|
|
|
|
if WEBHOOK_SECRET_GITEA:
|
|
if not _verify_hmac(body, x_gitea_signature_256, WEBHOOK_SECRET_GITEA):
|
|
raise HTTPException(status_code=401, detail="invalid signature")
|
|
|
|
try:
|
|
payload = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=400, detail="invalid JSON body")
|
|
|
|
now = datetime.now(tz=timezone.utc)
|
|
event_type = x_gitea_event.lower()
|
|
normaliser = _GITEA_NORMALISERS.get(event_type)
|
|
if normaliser is None:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"unsupported Gitea event type: {event_type!r}",
|
|
)
|
|
|
|
envelope = normaliser(payload, now)
|
|
if envelope is None:
|
|
return {"status": "ignored", "reason": "action not mapped"}
|
|
|
|
from activity_core.event_type_registry import is_event_type_allowed
|
|
if not is_event_type_allowed(envelope.type):
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"event type {envelope.type!r} not registered or not accepted",
|
|
)
|
|
|
|
await _publish_to_nats(envelope)
|
|
return {"status": "accepted", "event_id": envelope.id}
|
|
|
|
|
|
@router.post("/github")
|
|
async def receive_github(
|
|
request: Request,
|
|
x_github_event: str = Header(default="", alias="X-GitHub-Event"),
|
|
x_hub_signature_256: str = Header(default="", alias="X-Hub-Signature-256"),
|
|
) -> dict[str, str]:
|
|
body = await request.body()
|
|
|
|
if WEBHOOK_SECRET_GITHUB:
|
|
if not _verify_hmac(body, x_hub_signature_256, WEBHOOK_SECRET_GITHUB):
|
|
raise HTTPException(status_code=401, detail="invalid signature")
|
|
|
|
try:
|
|
payload = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=400, detail="invalid JSON body")
|
|
|
|
now = datetime.now(tz=timezone.utc)
|
|
event_type = x_github_event.lower()
|
|
normaliser = _GITHUB_NORMALISERS.get(event_type)
|
|
if normaliser is None:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"unsupported GitHub event type: {event_type!r}",
|
|
)
|
|
|
|
envelope = normaliser(payload, now)
|
|
if envelope is None:
|
|
return {"status": "ignored", "reason": "action not mapped"}
|
|
|
|
from activity_core.event_type_registry import is_event_type_allowed
|
|
if not is_event_type_allowed(envelope.type):
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"event type {envelope.type!r} not registered or not accepted",
|
|
)
|
|
|
|
await _publish_to_nats(envelope)
|
|
return {"status": "accepted", "event_id": envelope.id}
|