feat(WP-0003b): parser, workflow wiring, triggers, webhooks
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>
This commit is contained in:
parent
dc20c44a44
commit
176867cbe3
18 changed files with 1106 additions and 55 deletions
246
src/activity_core/webhook_receiver.py
Normal file
246
src/activity_core/webhook_receiver.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
"""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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue