Compare commits
2 commits
8f5145eaac
...
e270d10832
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e270d10832 | ||
|
|
35d258935a |
4 changed files with 159 additions and 4 deletions
|
|
@ -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) ---")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
53
tests/test_kin_104_regression.py
Normal file
53
tests/test_kin_104_regression.py
Normal 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"
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue