""" Kin — post-pipeline hooks. Runs configured commands (e.g. npm run build) after pipeline completion. """ import fnmatch import sqlite3 import subprocess import time from dataclasses import dataclass from typing import Any @dataclass class HookResult: hook_id: int name: str success: bool exit_code: int output: str error: str duration_seconds: float # --------------------------------------------------------------------------- # CRUD # --------------------------------------------------------------------------- def create_hook( conn: sqlite3.Connection, project_id: str, name: str, event: str, command: str, trigger_module_path: str | None = None, trigger_module_type: str | None = None, working_dir: str | None = None, timeout_seconds: int = 120, ) -> dict: """Create a hook and return it as dict.""" cur = conn.execute( """INSERT INTO hooks (project_id, name, event, trigger_module_path, trigger_module_type, command, working_dir, timeout_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (project_id, name, event, trigger_module_path, trigger_module_type, command, working_dir, timeout_seconds), ) conn.commit() return _get_hook(conn, cur.lastrowid) def get_hooks( conn: sqlite3.Connection, project_id: str, event: str | None = None, enabled_only: bool = True, ) -> list[dict]: """Get hooks for a project, optionally filtered by event.""" query = "SELECT * FROM hooks WHERE project_id = ?" params: list[Any] = [project_id] if event: query += " AND event = ?" params.append(event) if enabled_only: query += " AND enabled = 1" query += " ORDER BY id" rows = conn.execute(query, params).fetchall() return [dict(r) for r in rows] def update_hook(conn: sqlite3.Connection, hook_id: int, **kwargs) -> None: """Update hook fields.""" if not kwargs: return sets = ", ".join(f"{k} = ?" for k in kwargs) vals = list(kwargs.values()) + [hook_id] conn.execute(f"UPDATE hooks SET {sets} WHERE id = ?", vals) conn.commit() def delete_hook(conn: sqlite3.Connection, hook_id: int) -> None: """Delete a hook by id.""" conn.execute("DELETE FROM hooks WHERE id = ?", (hook_id,)) conn.commit() def get_hook_logs( conn: sqlite3.Connection, project_id: str | None = None, hook_id: int | None = None, limit: int = 50, ) -> list[dict]: """Get hook execution logs.""" query = "SELECT * FROM hook_logs WHERE 1=1" params: list[Any] = [] if project_id: query += " AND project_id = ?" params.append(project_id) if hook_id is not None: query += " AND hook_id = ?" params.append(hook_id) query += " ORDER BY created_at DESC LIMIT ?" params.append(limit) rows = conn.execute(query, params).fetchall() return [dict(r) for r in rows] # --------------------------------------------------------------------------- # Execution # --------------------------------------------------------------------------- def run_hooks( conn: sqlite3.Connection, project_id: str, task_id: str | None, event: str, task_modules: list[dict], ) -> list[HookResult]: """Run matching hooks for the given event and module list. Never raises — hook failures are logged but don't affect the pipeline. """ hooks = get_hooks(conn, project_id, event=event) results = [] for hook in hooks: if hook["trigger_module_path"] is not None: pattern = hook["trigger_module_path"] matched = any( fnmatch.fnmatch(m.get("path", ""), pattern) for m in task_modules ) if not matched: continue result = _execute_hook(conn, hook, project_id, task_id) results.append(result) return results # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _get_hook(conn: sqlite3.Connection, hook_id: int) -> dict: row = conn.execute("SELECT * FROM hooks WHERE id = ?", (hook_id,)).fetchone() return dict(row) if row else {} def _execute_hook( conn: sqlite3.Connection, hook: dict, project_id: str, task_id: str | None, ) -> HookResult: """Run a single hook command and log the result.""" start = time.monotonic() output = "" error = "" exit_code = -1 success = False try: proc = subprocess.run( hook["command"], shell=True, cwd=hook.get("working_dir") or None, capture_output=True, text=True, timeout=hook.get("timeout_seconds") or 120, ) output = proc.stdout or "" error = proc.stderr or "" exit_code = proc.returncode success = exit_code == 0 except subprocess.TimeoutExpired: error = f"Hook timed out after {hook.get('timeout_seconds', 120)}s" exit_code = 124 except Exception as e: error = str(e) exit_code = -1 duration = time.monotonic() - start _log_hook_run( conn, hook_id=hook["id"], project_id=project_id, task_id=task_id, success=success, exit_code=exit_code, output=output, error=error, duration_seconds=duration, ) return HookResult( hook_id=hook["id"], name=hook["name"], success=success, exit_code=exit_code, output=output, error=error, duration_seconds=duration, ) def _log_hook_run( conn: sqlite3.Connection, hook_id: int, project_id: str, task_id: str | None, success: bool, exit_code: int, output: str, error: str, duration_seconds: float, ) -> None: conn.execute( """INSERT INTO hook_logs (hook_id, project_id, task_id, success, exit_code, output, error, duration_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (hook_id, project_id, task_id, int(success), exit_code, output, error, duration_seconds), ) conn.commit()