PM-trigger via Gitea: pm-task.sh + webhook routing on pm:plan label + idempotency

This commit is contained in:
danny8632
2026-05-12 07:24:25 +00:00
parent b3ab9639af
commit dc61d92eb1
4 changed files with 232 additions and 22 deletions

View File

@@ -11,6 +11,7 @@ RestartSec=3
StandardOutput=journal
StandardError=journal
Environment=PYTHONUNBUFFERED=1
Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[Install]
WantedBy=multi-user.target

View File

@@ -28,7 +28,9 @@ 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")
LABEL = os.environ.get("AGENT_LABEL", "agent:assign")
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(
@@ -47,27 +49,29 @@ def verify_signature(body: bytes, sig_header: str) -> bool:
return hmac.compare_digest(mac, sig_header)
def should_dispatch(event: str, payload: dict) -> tuple[bool, str | None, int | None]:
"""Decide if this delivery should kick off a dev-task. Returns (yes, repo, issue#)."""
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 False, None, None
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", [])]
if action == "opened" and LABEL in labels:
return True, repo, number
if action == "label_updated" and LABEL in labels:
# Gitea fires label_updated for both add and remove; we only care about presence
return True, repo, number
return False, None, None
# 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(repo: str, issue: int):
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-{repo.replace('/', '_')}-issue-{issue}-{int(time.time())}.log"
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,
@@ -80,7 +84,18 @@ def dispatch(repo: str, issue: int):
]
with open(fn, "wb") as f:
rc = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT).returncode
log.info("dispatch ssh rc=%d", rc)
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):
@@ -99,11 +114,13 @@ class WebhookHandler(http.server.BaseHTTPRequestHandler):
except json.JSONDecodeError:
self.send_response(400); self.end_headers(); self.wfile.write(b"bad json\n"); return
ok, repo, issue = should_dispatch(ev, payload)
if ok and repo and issue is not None:
threading.Thread(target=dispatch, args=(repo, issue), daemon=True).start()
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(b"dispatched\n")
self.wfile.write(f"dispatched {kind}\n".encode())
else:
self.send_response(200); self.end_headers()
self.wfile.write(b"ignored\n")
@@ -123,6 +140,6 @@ class ThreadedServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
if __name__ == "__main__":
log.info("agent-webhook listening on %s:%d, target=%s, label=%s",
LISTEN_HOST, LISTEN_PORT, DEV_HOST, LABEL)
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()