kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-17 21:17:19 +02:00
parent 61212b6605
commit 1e8253d6e8
2 changed files with 349 additions and 0 deletions

View file

@ -5,14 +5,27 @@ Root cause: Makefile test target used bare `pytest tests/` which resolves to the
system pytest (Python 3.14). Tests written for Python 3.11 could fail under 3.14. system pytest (Python 3.14). Tests written for Python 3.11 could fail under 3.14.
Fix: Makefile test target changed to `python3.11 -m pytest tests/`. Fix: Makefile test target changed to `python3.11 -m pytest tests/`.
PR fix: _detect_test_command() now returns sys.executable -m pytest for
pyproject.toml/setup.py projects (was bare 'pytest').
Coverage: Coverage:
(1) Makefile test target uses python3.11, not bare pytest (1) Makefile test target uses python3.11, not bare pytest
(2) _detect_test_command still returns 'make test' for projects with Makefile (2) _detect_test_command still returns 'make test' for projects with Makefile
(3) _detect_test_command returns sys.executable -m pytest for pyproject.toml/setup.py
(4) Pipeline auto-test uses project.test_command when explicitly set
(5) Pipeline auto-test uses _detect_test_command when project.test_command is NULL
(6) PATCH /api/projects: test_command absent DB field unchanged (decision #580)
(7) Regression: pipeline auto-test early return (no test framework) does not crash
""" """
import sys
import re import re
from pathlib import Path from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
from core.db import init_db
from core import models
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -73,3 +86,177 @@ class TestDetectTestCommandUnchanged:
assert result == "make test", ( assert result == "make test", (
f"kin project should detect 'make test', got {result!r}" f"kin project should detect 'make test', got {result!r}"
) )
# ---------------------------------------------------------------------------
# (3) _detect_test_command: pyproject.toml/setup.py → sys.executable -m pytest
# ---------------------------------------------------------------------------
class TestDetectTestCommandSysExecutable:
def test_detect_pyproject_returns_sys_executable_pytest(self, tmp_path):
"""pyproject.toml project must get sys.executable -m pytest, not bare pytest."""
from agents.runner import _detect_test_command
(tmp_path / "pyproject.toml").write_text("[build-system]\n")
result = _detect_test_command(str(tmp_path))
assert result == f"{sys.executable} -m pytest"
def test_detect_setup_py_returns_sys_executable_pytest(self, tmp_path):
"""setup.py project must get sys.executable -m pytest, not bare pytest."""
from agents.runner import _detect_test_command
(tmp_path / "setup.py").write_text("from setuptools import setup\nsetup()\n")
result = _detect_test_command(str(tmp_path))
assert result == f"{sys.executable} -m pytest"
def test_detect_pyproject_not_bare_pytest(self, tmp_path):
"""Regression: _detect_test_command must not return bare 'pytest' for pyproject.toml."""
from agents.runner import _detect_test_command
(tmp_path / "pyproject.toml").write_text("[build-system]\n")
result = _detect_test_command(str(tmp_path))
assert result != "pytest", (
"Must not use bare 'pytest' — would resolve to wrong Python version"
)
# ---------------------------------------------------------------------------
# Shared fixture for pipeline auto-test tests
# ---------------------------------------------------------------------------
@pytest.fixture
def conn_kin109():
c = init_db(":memory:")
models.create_project(c, "proj", "Proj", "/tmp/proj", tech_stack=["python"])
models.create_task(c, "PROJ-001", "proj", "Fix bug", brief={"route_type": "debug"})
yield c
c.close()
def _claude_mock_success():
m = MagicMock()
m.stdout = '{"result": "done", "usage": {"total_tokens": 100}, "cost_usd": 0.001}'
m.stderr = ""
m.returncode = 0
return m
# ---------------------------------------------------------------------------
# (4/5) Pipeline auto-test: project.test_command priority vs auto-detect
# ---------------------------------------------------------------------------
class TestAutoTestCommandPriority:
@patch("agents.runner._run_project_tests")
@patch("agents.runner.subprocess.run")
def test_uses_project_test_command_when_set(self, mock_run, mock_tests, conn_kin109, tmp_path):
"""When project.test_command is set, pipeline auto-test must use that command."""
mock_run.return_value = _claude_mock_success()
mock_tests.return_value = {"success": True, "output": "ok", "returncode": 0}
(tmp_path / "pyproject.toml").write_text("[build-system]\n")
models.update_project(
conn_kin109, "proj",
test_command="my_custom_runner",
auto_test_enabled=True,
path=str(tmp_path),
)
from agents.runner import run_pipeline
run_pipeline(conn_kin109, "PROJ-001", [{"role": "backend_dev", "brief": "fix it"}])
mock_tests.assert_called_once()
assert mock_tests.call_args.args[1] == "my_custom_runner"
@patch("agents.runner._run_project_tests")
@patch("agents.runner.subprocess.run")
def test_uses_detect_when_test_command_null(self, mock_run, mock_tests, conn_kin109, tmp_path):
"""When project.test_command is NULL, pipeline auto-test uses _detect_test_command result."""
mock_run.return_value = _claude_mock_success()
mock_tests.return_value = {"success": True, "output": "ok", "returncode": 0}
(tmp_path / "pyproject.toml").write_text("[build-system]\n")
models.update_project(
conn_kin109, "proj",
test_command=None,
auto_test_enabled=True,
path=str(tmp_path),
)
from agents.runner import run_pipeline
run_pipeline(conn_kin109, "PROJ-001", [{"role": "backend_dev", "brief": "fix it"}])
mock_tests.assert_called_once()
assert mock_tests.call_args.args[1] == f"{sys.executable} -m pytest"
# ---------------------------------------------------------------------------
# (6) PATCH /api/projects: test_command absent → DB field unchanged (decision #580)
# ---------------------------------------------------------------------------
class TestPatchProjectTestCommand:
@pytest.fixture
def client(self, tmp_path):
import web.api as api_module
api_module.DB_PATH = tmp_path / "test.db"
from fastapi.testclient import TestClient
from web.api import app
c = TestClient(app)
c.post("/api/projects", json={"id": "px", "name": "PX", "path": "/px"})
# Pre-set a test_command value
c.patch("/api/projects/px", json={"test_command": "existing_runner"})
return c
def test_patch_without_test_command_does_not_change_db(self, client):
"""PATCH without test_command field must NOT overwrite the existing value."""
r = client.patch("/api/projects/px", json={"auto_test_enabled": False})
assert r.status_code == 200
r2 = client.get("/api/projects/px")
assert r2.status_code == 200
assert r2.json()["test_command"] == "existing_runner"
def test_patch_with_test_command_updates_db(self, client):
"""PATCH with test_command sets the new value in DB."""
r = client.patch("/api/projects/px", json={"test_command": "new_runner"})
assert r.status_code == 200
r2 = client.get("/api/projects/px")
assert r2.json()["test_command"] == "new_runner"
def test_patch_with_empty_test_command_stores_null(self, client):
"""PATCH with test_command='' clears it to NULL (enables auto-detect)."""
r = client.patch("/api/projects/px", json={"test_command": ""})
assert r.status_code == 200
r2 = client.get("/api/projects/px")
assert r2.json()["test_command"] is None
# ---------------------------------------------------------------------------
# (7) Regression: auto-test early return (no framework detected) does not crash
# ---------------------------------------------------------------------------
class TestAutoTestEarlyReturn:
@patch("agents.runner.subprocess.run")
def test_pipeline_skips_auto_test_when_no_framework_detected(self, mock_run, conn_kin109, tmp_path):
"""When no test framework is detected and test_command is NULL, pipeline must
skip auto-test and return success not crash."""
mock_run.return_value = _claude_mock_success()
# tmp_path has no Makefile, pyproject.toml, package.json, setup.py, tsconfig.json
# → _detect_test_command returns None → auto-test skipped
models.update_project(
conn_kin109, "proj",
test_command=None,
auto_test_enabled=True,
path=str(tmp_path),
)
from agents.runner import run_pipeline
result = run_pipeline(conn_kin109, "PROJ-001", [{"role": "backend_dev", "brief": "fix"}])
assert isinstance(result, dict)
assert result["success"] is True
# The skipped auto-test step must be in results
skipped = [r for r in result["results"] if r.get("_skipped") and r.get("_project_test")]
assert len(skipped) == 1

View file

@ -0,0 +1,162 @@
"""Regression tests for KIN-110 — Followup button for blocked tasks.
Verifies:
1. POST /api/tasks/{id}/followup endpoint exists and responds for blocked task
2. Endpoint returns 404 for non-existent task
3. Endpoint works for any task status (not just blocked)
4. Response contains expected fields: created, pending_actions, needs_decision
"""
import json
import pytest
from unittest.mock import patch, MagicMock
import web.api as api_module
@pytest.fixture
def client(tmp_path):
db_path = tmp_path / "test.db"
api_module.DB_PATH = db_path
from web.api import app
from fastapi.testclient import TestClient
c = TestClient(app)
c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"})
c.post("/api/tasks", json={"project_id": "p1", "title": "Fix auth"})
return c
@pytest.fixture
def blocked_client(tmp_path):
"""Client with a blocked task seeded."""
db_path = tmp_path / "test.db"
api_module.DB_PATH = db_path
from web.api import app
from fastapi.testclient import TestClient
from core.db import init_db
from core import models
c = TestClient(app)
c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"})
c.post("/api/tasks", json={"project_id": "p1", "title": "Blocked task"})
# Set task to blocked status
conn = init_db(db_path)
models.update_task(conn, "P1-001", status="blocked")
conn.close()
return c
class TestFollowupEndpoint:
"""Tests for POST /api/tasks/{id}/followup endpoint."""
@patch("agents.runner._run_claude")
def test_followup_blocked_task_returns_200(self, mock_claude, blocked_client):
"""POST /followup на blocked-задаче должен вернуть 200."""
mock_claude.return_value = {
"output": json.dumps([
{"title": "Fix dependency", "type": "hotfix", "priority": 2,
"brief": "Add missing dep"},
]),
"returncode": 0,
}
r = blocked_client.post("/api/tasks/P1-001/followup", json={})
assert r.status_code == 200
@patch("agents.runner._run_claude")
def test_followup_returns_created_tasks(self, mock_claude, blocked_client):
"""Ответ /followup содержит список created с созданными задачами."""
mock_claude.return_value = {
"output": json.dumps([
{"title": "Fix dependency", "type": "hotfix", "priority": 2,
"brief": "Add missing dep"},
]),
"returncode": 0,
}
r = blocked_client.post("/api/tasks/P1-001/followup", json={})
assert r.status_code == 200
data = r.json()
assert "created" in data
assert len(data["created"]) == 1
assert data["created"][0]["title"] == "Fix dependency"
@patch("agents.runner._run_claude")
def test_followup_response_has_required_fields(self, mock_claude, blocked_client):
"""Ответ /followup содержит поля created, pending_actions, needs_decision."""
mock_claude.return_value = {"output": "[]", "returncode": 0}
r = blocked_client.post("/api/tasks/P1-001/followup", json={})
assert r.status_code == 200
data = r.json()
assert "created" in data
assert "pending_actions" in data
assert "needs_decision" in data
def test_followup_nonexistent_task_returns_404(self, blocked_client):
"""POST /followup на несуществующую задачу → 404."""
r = blocked_client.post("/api/tasks/NOPE/followup", json={})
assert r.status_code == 404
@patch("agents.runner._run_claude")
def test_followup_dry_run_does_not_create_tasks(self, mock_claude, blocked_client):
"""dry_run=true не создаёт задачи в БД."""
from core.db import init_db
from core import models
mock_claude.return_value = {
"output": json.dumps([
{"title": "Dry run task", "type": "hotfix", "priority": 3,
"brief": "should not be created"},
]),
"returncode": 0,
}
r = blocked_client.post("/api/tasks/P1-001/followup", json={"dry_run": True})
assert r.status_code == 200
# No tasks should be created in DB (only original P1-001 remains)
conn = init_db(api_module.DB_PATH)
tasks = models.list_tasks(conn, project_id="p1")
conn.close()
assert len(tasks) == 1 # Only P1-001, no new tasks
@patch("agents.runner._run_claude")
def test_followup_creates_child_tasks_in_db(self, mock_claude, blocked_client):
"""Созданные followup-задачи сохраняются в БД с parent_task_id."""
from core.db import init_db
from core import models
mock_claude.return_value = {
"output": json.dumps([
{"title": "New dep task", "type": "feature", "priority": 4,
"brief": "Install dependency"},
]),
"returncode": 0,
}
r = blocked_client.post("/api/tasks/P1-001/followup", json={})
assert r.status_code == 200
conn = init_db(api_module.DB_PATH)
tasks = models.list_tasks(conn, project_id="p1")
conn.close()
# Original task + 1 followup
assert len(tasks) == 2
followup = next((t for t in tasks if t["id"] != "P1-001"), None)
assert followup is not None
assert followup["parent_task_id"] == "P1-001"
@patch("agents.runner._run_claude")
def test_followup_pending_actions_for_permission_blocked_items(
self, mock_claude, blocked_client
):
"""Элементы с permission-блокером попадают в pending_actions, не в created."""
mock_claude.return_value = {
"output": json.dumps([
{"title": "Normal task", "type": "hotfix", "priority": 2,
"brief": "Just a fix"},
{"title": "Ручное применение .env",
"type": "hotfix", "priority": 3,
"brief": "Не получили разрешение на запись"},
]),
"returncode": 0,
}
r = blocked_client.post("/api/tasks/P1-001/followup", json={})
assert r.status_code == 200
data = r.json()
assert len(data["created"]) == 1
assert len(data["pending_actions"]) == 1
assert data["needs_decision"] is True