feat: add post-pipeline hooks (KIN-003)
- core/hooks.py: HookRunner с CRUD, run_hooks(), _execute_hook(), логированием - core/db.py: новые таблицы hooks и hook_logs в схеме - agents/runner.py: вызов run_hooks() после завершения pipeline - tests/test_hooks.py: 23 теста (CRUD, fnmatch-матчинг, выполнение, таймаут) Хуки запускаются синхронно после update_task(status="review"). Ошибка хука логируется, не блокирует пайплайн. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bf38532f59
commit
d311c2fb66
4 changed files with 534 additions and 0 deletions
|
|
@ -13,6 +13,7 @@ from typing import Any
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
from core.context_builder import build_context, format_prompt
|
from core.context_builder import build_context, format_prompt
|
||||||
|
from core.hooks import run_hooks
|
||||||
|
|
||||||
|
|
||||||
def run_agent(
|
def run_agent(
|
||||||
|
|
@ -465,6 +466,11 @@ def run_pipeline(
|
||||||
)
|
)
|
||||||
models.update_task(conn, task_id, status="review")
|
models.update_task(conn, task_id, status="review")
|
||||||
|
|
||||||
|
# Run post-pipeline hooks (failures don't affect pipeline status)
|
||||||
|
task_modules = models.get_modules(conn, project_id)
|
||||||
|
run_hooks(conn, project_id, task_id,
|
||||||
|
event="pipeline_completed", task_modules=task_modules)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"steps_completed": len(steps),
|
"steps_completed": len(steps),
|
||||||
|
|
|
||||||
29
core/db.py
29
core/db.py
|
|
@ -103,6 +103,35 @@ CREATE TABLE IF NOT EXISTS pipelines (
|
||||||
completed_at DATETIME
|
completed_at DATETIME
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Post-pipeline хуки
|
||||||
|
CREATE TABLE IF NOT EXISTS hooks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
trigger_module_path TEXT,
|
||||||
|
trigger_module_type TEXT,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
working_dir TEXT,
|
||||||
|
timeout_seconds INTEGER DEFAULT 120,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Лог выполнений хуков
|
||||||
|
CREATE TABLE IF NOT EXISTS hook_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hook_id INTEGER NOT NULL REFERENCES hooks(id),
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
task_id TEXT,
|
||||||
|
success INTEGER NOT NULL,
|
||||||
|
exit_code INTEGER,
|
||||||
|
output TEXT,
|
||||||
|
error TEXT,
|
||||||
|
duration_seconds REAL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
-- Кросс-проектные зависимости
|
-- Кросс-проектные зависимости
|
||||||
CREATE TABLE IF NOT EXISTS project_links (
|
CREATE TABLE IF NOT EXISTS project_links (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
|
||||||
224
core/hooks.py
Normal file
224
core/hooks.py
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
"""
|
||||||
|
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()
|
||||||
275
tests/test_hooks.py
Normal file
275
tests/test_hooks.py
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
"""Tests for core/hooks.py — post-pipeline hook execution."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from core.db import init_db
|
||||||
|
from core import models
|
||||||
|
from core.hooks import (
|
||||||
|
create_hook, get_hooks, update_hook, delete_hook,
|
||||||
|
run_hooks, get_hook_logs, HookResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn():
|
||||||
|
c = init_db(":memory:")
|
||||||
|
models.create_project(c, "vdol", "ВДОЛЬ", "~/projects/vdolipoperek",
|
||||||
|
tech_stack=["vue3"])
|
||||||
|
models.create_task(c, "VDOL-001", "vdol", "Fix bug")
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def frontend_hook(conn):
|
||||||
|
return create_hook(
|
||||||
|
conn,
|
||||||
|
project_id="vdol",
|
||||||
|
name="rebuild-frontend",
|
||||||
|
event="pipeline_completed",
|
||||||
|
command="npm run build",
|
||||||
|
trigger_module_path="web/frontend/*",
|
||||||
|
working_dir="/tmp",
|
||||||
|
timeout_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCrud:
|
||||||
|
def test_create_hook(self, conn):
|
||||||
|
hook = create_hook(conn, "vdol", "my-hook", "pipeline_completed", "make build")
|
||||||
|
assert hook["id"] is not None
|
||||||
|
assert hook["project_id"] == "vdol"
|
||||||
|
assert hook["name"] == "my-hook"
|
||||||
|
assert hook["command"] == "make build"
|
||||||
|
assert hook["enabled"] == 1
|
||||||
|
|
||||||
|
def test_get_hooks_by_project(self, conn, frontend_hook):
|
||||||
|
hooks = get_hooks(conn, "vdol")
|
||||||
|
assert len(hooks) == 1
|
||||||
|
assert hooks[0]["name"] == "rebuild-frontend"
|
||||||
|
|
||||||
|
def test_get_hooks_filter_by_event(self, conn, frontend_hook):
|
||||||
|
create_hook(conn, "vdol", "other", "step_completed", "echo done")
|
||||||
|
hooks = get_hooks(conn, "vdol", event="pipeline_completed")
|
||||||
|
assert len(hooks) == 1
|
||||||
|
assert hooks[0]["name"] == "rebuild-frontend"
|
||||||
|
|
||||||
|
def test_get_hooks_disabled_excluded(self, conn, frontend_hook):
|
||||||
|
update_hook(conn, frontend_hook["id"], enabled=0)
|
||||||
|
hooks = get_hooks(conn, "vdol", enabled_only=True)
|
||||||
|
assert len(hooks) == 0
|
||||||
|
|
||||||
|
def test_get_hooks_disabled_included_when_flag_off(self, conn, frontend_hook):
|
||||||
|
update_hook(conn, frontend_hook["id"], enabled=0)
|
||||||
|
hooks = get_hooks(conn, "vdol", enabled_only=False)
|
||||||
|
assert len(hooks) == 1
|
||||||
|
|
||||||
|
def test_update_hook(self, conn, frontend_hook):
|
||||||
|
update_hook(conn, frontend_hook["id"], command="npm run build:prod", timeout_seconds=180)
|
||||||
|
hooks = get_hooks(conn, "vdol", enabled_only=False)
|
||||||
|
assert hooks[0]["command"] == "npm run build:prod"
|
||||||
|
assert hooks[0]["timeout_seconds"] == 180
|
||||||
|
|
||||||
|
def test_delete_hook(self, conn, frontend_hook):
|
||||||
|
delete_hook(conn, frontend_hook["id"])
|
||||||
|
hooks = get_hooks(conn, "vdol", enabled_only=False)
|
||||||
|
assert len(hooks) == 0
|
||||||
|
|
||||||
|
def test_get_hooks_wrong_project(self, conn, frontend_hook):
|
||||||
|
hooks = get_hooks(conn, "nonexistent")
|
||||||
|
assert hooks == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module matching (fnmatch)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestModuleMatching:
|
||||||
|
def _make_proc(self, returncode=0, stdout="ok", stderr=""):
|
||||||
|
m = MagicMock()
|
||||||
|
m.returncode = returncode
|
||||||
|
m.stdout = stdout
|
||||||
|
m.stderr = stderr
|
||||||
|
return m
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_hook_runs_when_module_matches(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = self._make_proc()
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
results = run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].name == "rebuild-frontend"
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_hook_skipped_when_no_module_matches(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = self._make_proc()
|
||||||
|
modules = [{"path": "core/models.py", "name": "models"}]
|
||||||
|
results = run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
assert len(results) == 0
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_hook_runs_without_module_filter(self, mock_run, conn):
|
||||||
|
mock_run.return_value = self._make_proc()
|
||||||
|
create_hook(conn, "vdol", "always-run", "pipeline_completed", "echo done")
|
||||||
|
results = run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=[])
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_hook_skipped_on_wrong_event(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = self._make_proc()
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
results = run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="step_completed", task_modules=modules)
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_hook_skipped_when_disabled(self, mock_run, conn, frontend_hook):
|
||||||
|
update_hook(conn, frontend_hook["id"], enabled=0)
|
||||||
|
mock_run.return_value = self._make_proc()
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
results = run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Execution and logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestExecution:
|
||||||
|
def _make_proc(self, returncode=0, stdout="built!", stderr=""):
|
||||||
|
m = MagicMock()
|
||||||
|
m.returncode = returncode
|
||||||
|
m.stdout = stdout
|
||||||
|
m.stderr = stderr
|
||||||
|
return m
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_successful_hook_result(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = self._make_proc(returncode=0, stdout="built!")
|
||||||
|
modules = [{"path": "web/frontend/index.ts", "name": "index"}]
|
||||||
|
results = run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
r = results[0]
|
||||||
|
assert r.success is True
|
||||||
|
assert r.exit_code == 0
|
||||||
|
assert r.output == "built!"
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_failed_hook_result(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = self._make_proc(returncode=1, stderr="Module not found")
|
||||||
|
modules = [{"path": "web/frontend/index.ts", "name": "index"}]
|
||||||
|
results = run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
r = results[0]
|
||||||
|
assert r.success is False
|
||||||
|
assert r.exit_code == 1
|
||||||
|
assert "Module not found" in r.error
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_hook_run_logged_to_db(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = self._make_proc(returncode=0, stdout="ok")
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
|
||||||
|
logs = get_hook_logs(conn, project_id="vdol")
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0]["hook_id"] == frontend_hook["id"]
|
||||||
|
assert logs[0]["task_id"] == "VDOL-001"
|
||||||
|
assert logs[0]["success"] == 1
|
||||||
|
assert logs[0]["exit_code"] == 0
|
||||||
|
assert logs[0]["output"] == "ok"
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_failed_hook_logged_to_db(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = self._make_proc(returncode=2, stderr="error!")
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
|
||||||
|
logs = get_hook_logs(conn, project_id="vdol")
|
||||||
|
assert logs[0]["success"] == 0
|
||||||
|
assert logs[0]["exit_code"] == 2
|
||||||
|
assert "error!" in logs[0]["error"]
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_timeout_handled_gracefully(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired(cmd="npm run build", timeout=60)
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
# Must not raise
|
||||||
|
results = run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
r = results[0]
|
||||||
|
assert r.success is False
|
||||||
|
assert r.exit_code == 124
|
||||||
|
assert "timed out" in r.error
|
||||||
|
|
||||||
|
logs = get_hook_logs(conn, project_id="vdol")
|
||||||
|
assert logs[0]["success"] == 0
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_exception_handled_gracefully(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.side_effect = OSError("npm not found")
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
results = run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
r = results[0]
|
||||||
|
assert r.success is False
|
||||||
|
assert "npm not found" in r.error
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_command_uses_working_dir(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = self._make_proc()
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
call_kwargs = mock_run.call_args[1]
|
||||||
|
assert call_kwargs["cwd"] == "/tmp"
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_shell_true_used(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = self._make_proc()
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
call_kwargs = mock_run.call_args[1]
|
||||||
|
assert call_kwargs["shell"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_hook_logs filters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGetHookLogs:
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_filter_by_hook_id(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||||
|
hook2 = create_hook(conn, "vdol", "second", "pipeline_completed", "echo 2")
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
|
||||||
|
logs = get_hook_logs(conn, hook_id=frontend_hook["id"])
|
||||||
|
assert all(l["hook_id"] == frontend_hook["id"] for l in logs)
|
||||||
|
|
||||||
|
@patch("core.hooks.subprocess.run")
|
||||||
|
def test_limit_respected(self, mock_run, conn, frontend_hook):
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||||
|
modules = [{"path": "web/frontend/App.vue", "name": "App"}]
|
||||||
|
for _ in range(5):
|
||||||
|
run_hooks(conn, "vdol", "VDOL-001",
|
||||||
|
event="pipeline_completed", task_modules=modules)
|
||||||
|
logs = get_hook_logs(conn, project_id="vdol", limit=3)
|
||||||
|
assert len(logs) == 3
|
||||||
Loading…
Add table
Add a link
Reference in a new issue