Compare commits

..

2 commits

Author SHA1 Message Date
Gros Frumos
e270d10832 kin: auto-commit after pipeline 2026-03-17 20:21:52 +02:00
Gros Frumos
35d258935a KIN-103: Добавить поддержку worktrees_enabled в PATCH /api/projects/{project_id}
- Добавлено поле worktrees_enabled: bool | None = None в класс ProjectPatch
- Добавлена проверка в has_any для обнаружения изменений
- Добавлена обработка поля при преобразовании в целое число для БД

DB schema и runner уже содержат поддержку worktrees_enabled.
GET /api/projects/{id} возвращает поле автоматически через SELECT *.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-17 20:18:51 +02:00
4 changed files with 159 additions and 4 deletions

View file

@ -6,7 +6,7 @@ Uses core.models for all data access, never raw SQL.
import json import json
import os import os
import sys import sys
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import click import click
@ -618,13 +618,13 @@ def run_task(ctx, task_id, dry_run, allow_write):
# Step 1: PM decomposes # Step 1: PM decomposes
click.echo("Running PM to decompose task...") click.echo("Running PM to decompose task...")
pm_started_at = datetime.utcnow().isoformat() pm_started_at = datetime.now(timezone.utc).isoformat()
pm_result = run_agent( pm_result = run_agent(
conn, "pm", task_id, project_id, conn, "pm", task_id, project_id,
model="sonnet", dry_run=dry_run, model="sonnet", dry_run=dry_run,
allow_write=allow_write, noninteractive=is_noninteractive, allow_write=allow_write, noninteractive=is_noninteractive,
) )
pm_ended_at = datetime.utcnow().isoformat() pm_ended_at = datetime.now(timezone.utc).isoformat()
if dry_run: if dry_run:
click.echo("\n--- PM Prompt (dry-run) ---") click.echo("\n--- PM Prompt (dry-run) ---")

View file

@ -2342,3 +2342,102 @@ def test_get_pipeline_logs_since_id_returns_pm_entries(client):
done_log = next(log for log in logs if "PM done" in log["message"]) done_log = next(log for log in logs if "PM done" in log["message"])
assert done_log["extra_json"]["steps_count"] == 2 assert done_log["extra_json"]["steps_count"] == 2
assert done_log["extra_json"]["tokens_used"] == 1000 assert done_log["extra_json"]["tokens_used"] == 1000
# ---------------------------------------------------------------------------
# KIN-103 — PATCH /api/projects/{id} — worktrees_enabled toggle
# ---------------------------------------------------------------------------
def test_patch_project_worktrees_enabled_true(client):
"""PATCH с worktrees_enabled=true → 200, поле установлено в 1."""
r = client.patch("/api/projects/p1", json={"worktrees_enabled": True})
assert r.status_code == 200
assert r.json()["worktrees_enabled"] == 1
def test_patch_project_worktrees_enabled_false(client):
"""После включения PATCH с worktrees_enabled=false → 200, поле установлено в 0."""
client.patch("/api/projects/p1", json={"worktrees_enabled": True})
r = client.patch("/api/projects/p1", json={"worktrees_enabled": False})
assert r.status_code == 200
assert r.json()["worktrees_enabled"] == 0
def test_patch_project_worktrees_enabled_true_persisted_via_sql(client):
"""После PATCH worktrees_enabled=True прямой SQL подтверждает значение 1."""
client.patch("/api/projects/p1", json={"worktrees_enabled": True})
from core.db import init_db
conn = init_db(api_module.DB_PATH)
row = conn.execute("SELECT worktrees_enabled FROM projects WHERE id = 'p1'").fetchone()
conn.close()
assert row is not None
assert row[0] == 1
def test_patch_project_worktrees_enabled_false_persisted_via_sql(client):
"""После PATCH worktrees_enabled=False прямой SQL подтверждает значение 0."""
client.patch("/api/projects/p1", json={"worktrees_enabled": True})
client.patch("/api/projects/p1", json={"worktrees_enabled": False})
from core.db import init_db
conn = init_db(api_module.DB_PATH)
row = conn.execute("SELECT worktrees_enabled FROM projects WHERE id = 'p1'").fetchone()
conn.close()
assert row is not None
assert row[0] == 0
def test_patch_project_worktrees_enabled_null_before_first_update(client):
"""Новый проект имеет worktrees_enabled=0 (DEFAULT) до первого обновления."""
client.post("/api/projects", json={"id": "p_new", "name": "New", "path": "/new"})
from core.db import init_db
conn = init_db(api_module.DB_PATH)
row = conn.execute("SELECT worktrees_enabled FROM projects WHERE id = 'p_new'").fetchone()
conn.close()
assert row is not None
assert not row[0] # DEFAULT 0 или NULL — в любом случае falsy
def test_patch_project_worktrees_enabled_get_includes_field(client):
"""GET проекта включает worktrees_enabled в ответе."""
r = client.get("/api/projects/p1")
assert r.status_code == 200
data = r.json()
assert "worktrees_enabled" in data
def test_patch_project_worktrees_enabled_and_autocommit_together(client):
"""PATCH с worktrees_enabled и autocommit_enabled → оба поля обновлены."""
r = client.patch("/api/projects/p1", json={
"worktrees_enabled": True,
"autocommit_enabled": True,
})
assert r.status_code == 200
data = r.json()
assert data["worktrees_enabled"] == 1
assert data["autocommit_enabled"] == 1
def test_patch_project_worktrees_enabled_no_change_when_not_in_patch(client):
"""PATCH без worktrees_enabled → поле не меняется."""
# Сначала установим worktrees_enabled=1
client.patch("/api/projects/p1", json={"worktrees_enabled": True})
# Потом патч без worktrees_enabled не должен его менять
r = client.patch("/api/projects/p1", json={"autocommit_enabled": True})
assert r.status_code == 200
assert r.json()["worktrees_enabled"] == 1
def test_patch_project_worktrees_enabled_toggle_sequence(client):
"""Последовательные включение/выключение worktrees_enabled."""
# Включаем
r1 = client.patch("/api/projects/p1", json={"worktrees_enabled": True})
assert r1.json()["worktrees_enabled"] == 1
# Отключаем
r2 = client.patch("/api/projects/p1", json={"worktrees_enabled": False})
assert r2.json()["worktrees_enabled"] == 0
# Снова включаем
r3 = client.patch("/api/projects/p1", json={"worktrees_enabled": True})
assert r3.json()["worktrees_enabled"] == 1

View file

@ -0,0 +1,53 @@
"""Regression tests for KIN-104 — замена datetime.utcnow() на datetime.now(timezone.utc) в cli/main.py.
AC1: datetime.utcnow() отсутствует в cli/main.py.
AC2: timezone импортирован из datetime в cli/main.py.
AC3: datetime.now(timezone.utc).isoformat() возвращает строку с суффиксом +00:00 (aware datetime).
"""
import re
from datetime import datetime, timezone
from pathlib import Path
import pytest
CLI_MAIN = Path(__file__).parent.parent / "cli" / "main.py"
# ---------------------------------------------------------------------------
# AC1: utcnow() отсутствует в источнике
# ---------------------------------------------------------------------------
def test_cli_main_does_not_contain_utcnow():
"""cli/main.py не должен содержать вызовов datetime.utcnow()."""
source = CLI_MAIN.read_text(encoding="utf-8")
assert "utcnow" not in source, "Найден устаревший вызов utcnow() в cli/main.py"
# ---------------------------------------------------------------------------
# AC2: timezone импортирован
# ---------------------------------------------------------------------------
def test_cli_main_imports_timezone():
"""cli/main.py должен импортировать timezone из datetime."""
source = CLI_MAIN.read_text(encoding="utf-8")
# Принимаем: from datetime import datetime, timezone или from datetime import ..., timezone, ...
assert re.search(r"from datetime import [^#\n]*timezone", source), \
"timezone не импортирован из datetime в cli/main.py"
# ---------------------------------------------------------------------------
# AC3: aware datetime → isoformat содержит +00:00
# ---------------------------------------------------------------------------
def test_now_timezone_utc_isoformat_contains_utc_offset():
"""datetime.now(timezone.utc).isoformat() должен содержать '+00:00'."""
ts = datetime.now(timezone.utc).isoformat()
assert "+00:00" in ts, f"Ожидался суффикс +00:00, получено: {ts}"
def test_now_timezone_utc_is_aware():
"""datetime.now(timezone.utc) должен возвращать aware datetime (tzinfo не None)."""
dt = datetime.now(timezone.utc)
assert dt.tzinfo is not None, "datetime.now(timezone.utc) вернул naive datetime"

View file

@ -233,6 +233,7 @@ class ProjectPatch(BaseModel):
execution_mode: str | None = None execution_mode: str | None = None
autocommit_enabled: bool | None = None autocommit_enabled: bool | None = None
auto_test_enabled: bool | None = None auto_test_enabled: bool | None = None
worktrees_enabled: bool | None = None
obsidian_vault_path: str | None = None obsidian_vault_path: str | None = None
deploy_command: str | None = None deploy_command: str | None = None
deploy_host: str | None = None deploy_host: str | None = None
@ -251,7 +252,7 @@ class ProjectPatch(BaseModel):
def patch_project(project_id: str, body: ProjectPatch): def patch_project(project_id: str, body: ProjectPatch):
has_any = any([ has_any = any([
body.execution_mode, body.autocommit_enabled is not None, body.execution_mode, body.autocommit_enabled is not None,
body.auto_test_enabled is not None, body.auto_test_enabled is not None, body.worktrees_enabled is not None,
body.obsidian_vault_path, body.deploy_command is not None, body.obsidian_vault_path, body.deploy_command is not None,
body.deploy_host is not None, body.deploy_path is not None, body.deploy_host is not None, body.deploy_path is not None,
body.deploy_runtime is not None, body.deploy_restart_cmd is not None, body.deploy_runtime is not None, body.deploy_restart_cmd is not None,
@ -280,6 +281,8 @@ def patch_project(project_id: str, body: ProjectPatch):
fields["autocommit_enabled"] = int(body.autocommit_enabled) fields["autocommit_enabled"] = int(body.autocommit_enabled)
if body.auto_test_enabled is not None: if body.auto_test_enabled is not None:
fields["auto_test_enabled"] = int(body.auto_test_enabled) fields["auto_test_enabled"] = int(body.auto_test_enabled)
if body.worktrees_enabled is not None:
fields["worktrees_enabled"] = int(body.worktrees_enabled)
if body.obsidian_vault_path is not None: if body.obsidian_vault_path is not None:
fields["obsidian_vault_path"] = body.obsidian_vault_path fields["obsidian_vault_path"] = body.obsidian_vault_path
if body.deploy_command is not None: if body.deploy_command is not None: