kin: auto-commit after pipeline
This commit is contained in:
parent
61212b6605
commit
1e8253d6e8
2 changed files with 349 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
162
tests/test_kin_110_regression.py
Normal file
162
tests/test_kin_110_regression.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue