From 35d258935ad3ddaa2a0e09c1349749bb9db8c5b6 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Tue, 17 Mar 2026 20:18:51 +0200 Subject: [PATCH 1/2] =?UTF-8?q?KIN-103:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6?= =?UTF-8?q?=D0=BA=D1=83=20worktrees=5Fenabled=20=D0=B2=20PATCH=20/api/proj?= =?UTF-8?q?ects/{project=5Fid}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлено поле 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 --- web/api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/api.py b/web/api.py index b6fc146..86a0a83 100644 --- a/web/api.py +++ b/web/api.py @@ -233,6 +233,7 @@ class ProjectPatch(BaseModel): execution_mode: str | None = None autocommit_enabled: bool | None = None auto_test_enabled: bool | None = None + worktrees_enabled: bool | None = None obsidian_vault_path: str | None = None deploy_command: str | None = None deploy_host: str | None = None @@ -251,7 +252,7 @@ class ProjectPatch(BaseModel): def patch_project(project_id: str, body: ProjectPatch): has_any = any([ 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.deploy_host is not None, body.deploy_path 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) if body.auto_test_enabled is not None: 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: fields["obsidian_vault_path"] = body.obsidian_vault_path if body.deploy_command is not None: From e270d10832c4908a9d4727831fe4f0a87aad0b87 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Tue, 17 Mar 2026 20:21:52 +0200 Subject: [PATCH 2/2] kin: auto-commit after pipeline --- cli/main.py | 6 +- tests/test_api.py | 99 ++++++++++++++++++++++++++++++++ tests/test_kin_104_regression.py | 53 +++++++++++++++++ 3 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 tests/test_kin_104_regression.py diff --git a/cli/main.py b/cli/main.py index 6752d61..a8ae0cb 100644 --- a/cli/main.py +++ b/cli/main.py @@ -6,7 +6,7 @@ Uses core.models for all data access, never raw SQL. import json import os import sys -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path import click @@ -618,13 +618,13 @@ def run_task(ctx, task_id, dry_run, allow_write): # Step 1: PM decomposes 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( conn, "pm", task_id, project_id, model="sonnet", dry_run=dry_run, allow_write=allow_write, noninteractive=is_noninteractive, ) - pm_ended_at = datetime.utcnow().isoformat() + pm_ended_at = datetime.now(timezone.utc).isoformat() if dry_run: click.echo("\n--- PM Prompt (dry-run) ---") diff --git a/tests/test_api.py b/tests/test_api.py index 081824a..1107d71 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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"]) assert done_log["extra_json"]["steps_count"] == 2 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 diff --git a/tests/test_kin_104_regression.py b/tests/test_kin_104_regression.py new file mode 100644 index 0000000..81fe9e1 --- /dev/null +++ b/tests/test_kin_104_regression.py @@ -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"