Files
orchestrator/webhook/handler.py

146 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""
Gitea webhook handler — agent-coding-empire dispatcher.
Listens on TCP :18790 for Gitea webhook deliveries. When an issue acquires the
label "agent:assign" (or is opened with that label), SSH-triggers dev-task.sh
on dev-01 (192.168.1.29).
Verifies the X-Gitea-Signature HMAC header against WEBHOOK_SECRET from
/etc/agent/webhook-secret.
This is a deliberately tiny single-file service. It uses stdlib only.
"""
import hashlib
import hmac
import http.server
import json
import logging
import os
import socketserver
import subprocess
import threading
import time
from pathlib import Path
LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "18790"))
SECRET_FILE = Path(os.environ.get("WEBHOOK_SECRET_FILE", "/etc/agent/webhook-secret"))
DEV_HOST = os.environ.get("DEV_HOST", "agent@192.168.1.29")
SSH_KEY = os.environ.get("SSH_KEY", "/root/.ssh/id_ed25519_pve")
DEV_LABEL = os.environ.get("AGENT_LABEL", "agent:assign")
PM_LABEL = os.environ.get("PM_LABEL", "pm:plan")
PM_TASK_BIN = os.environ.get("PM_TASK_BIN", "/usr/local/bin/pm-task.sh")
LOG_DIR = Path(os.environ.get("HANDLER_LOG_DIR", "/var/log/agent-webhook"))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("webhook")
SECRET = SECRET_FILE.read_text().strip()
def verify_signature(body: bytes, sig_header: str) -> bool:
if not sig_header:
return False
mac = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, sig_header)
def route(event: str, payload: dict) -> tuple[str, str, int] | None:
"""Decide which task to dispatch. Returns (kind, repo, issue#) or None."""
if event != "issues":
return None
action = payload.get("action", "")
if action not in ("opened", "label_updated"):
return None
issue = payload.get("issue", {})
repo = payload.get("repository", {}).get("full_name")
number = issue.get("number")
labels = [l.get("name", "") for l in issue.get("labels", [])]
# PM takes priority over dev: a parent issue should be planned before any dev work fires
if PM_LABEL in labels:
return ("pm", repo, number)
if DEV_LABEL in labels:
return ("dev", repo, number)
return None
def dispatch_dev(repo: str, issue: int):
"""SSH into dev-01 and fire dev-task in the background."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
fn = LOG_DIR / f"dispatch-dev-{repo.replace('/', '_')}-issue-{issue}-{int(time.time())}.log"
log.info("dispatching dev-task for %s#%d -> %s", repo, issue, fn)
cmd = [
"ssh", "-i", SSH_KEY,
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
DEV_HOST,
f"PATH=$HOME/.local/bin:/usr/local/bin:$PATH "
f"nohup /usr/local/bin/dev-task.sh '{repo}' {issue} "
f">>/var/agent/logs/dispatch-issue-{issue}.log 2>&1 &"
]
with open(fn, "wb") as f:
rc = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT).returncode
log.info("dispatch-dev ssh rc=%d", rc)
def dispatch_pm(repo: str, issue: int):
"""Run pm-task.sh locally (claude is installed on this host)."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
fn = LOG_DIR / f"dispatch-pm-{repo.replace('/', '_')}-issue-{issue}-{int(time.time())}.log"
log.info("dispatching pm-task for %s#%d -> %s", repo, issue, fn)
cmd = [PM_TASK_BIN, repo, str(issue)]
with open(fn, "wb") as f:
rc = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT).returncode
log.info("dispatch-pm rc=%d", rc)
class WebhookHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(n)
sig = self.headers.get("X-Gitea-Signature", "")
ev = self.headers.get("X-Gitea-Event", "")
if not verify_signature(body, sig):
log.warning("bad signature for event=%s", ev)
self.send_response(401); self.end_headers(); self.wfile.write(b"bad sig\n"); return
try:
payload = json.loads(body)
except json.JSONDecodeError:
self.send_response(400); self.end_headers(); self.wfile.write(b"bad json\n"); return
r = route(ev, payload)
if r is not None:
kind, repo, issue = r
target = dispatch_pm if kind == "pm" else dispatch_dev
threading.Thread(target=target, args=(repo, issue), daemon=True).start()
self.send_response(202); self.end_headers()
self.wfile.write(f"dispatched {kind}\n".encode())
else:
self.send_response(200); self.end_headers()
self.wfile.write(b"ignored\n")
def do_GET(self):
if self.path == "/healthz":
self.send_response(200); self.end_headers(); self.wfile.write(b"ok\n")
else:
self.send_response(404); self.end_headers()
def log_message(self, fmt, *args):
log.info("%s - %s", self.address_string(), fmt % args)
class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
if __name__ == "__main__":
log.info("agent-webhook listening on %s:%d, dev=%s dev_label=%s pm_label=%s",
LISTEN_HOST, LISTEN_PORT, DEV_HOST, DEV_LABEL, PM_LABEL)
ThreadedServer((LISTEN_HOST, LISTEN_PORT), WebhookHandler).serve_forever()