PM-trigger via Gitea: pm-task.sh + webhook routing on pm:plan label + idempotency
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user