From 993362341b6d1915ee3b1e8c7e463ecdb72905c3 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Mon, 16 Mar 2026 08:38:49 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20KIN-067=20=D0=9F=D1=80=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B5=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=D1=81=D1=8F=20=D1=81=20=D0=BE=D0=B1=D1=81=D0=B8?= =?UTF-8?q?=D0=B4=D0=B8=D0=B0=D0=BD=D0=BE=D0=BC=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20'Sync=20error:=20Erro?= =?UTF-8?q?r:=20400=20Bad=20Request'.=20=D0=A0=D0=B0=D0=B7=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=D1=81=D1=8F=20=D1=81=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B1=D0=BB=D0=B5=D0=BC=D0=BE=D0=B9.=20=D0=A1=D0=B8?= =?UTF-8?q?=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D0=B1=D0=B5=20=D1=81=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=8B.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/followup.py | 15 +++++- tests/test_api.py | 62 +++++++++++++++++++++++++ web/frontend/src/components/Badge.vue | 5 ++ web/frontend/src/views/ProjectView.vue | 41 +++++++++------- web/frontend/src/views/SettingsView.vue | 1 + 5 files changed, 106 insertions(+), 18 deletions(-) diff --git a/core/followup.py b/core/followup.py index cb4c054..8129d07 100644 --- a/core/followup.py +++ b/core/followup.py @@ -24,6 +24,15 @@ PERMISSION_PATTERNS = [ ] +def _next_task_id( + conn: sqlite3.Connection, + project_id: str, + category: str | None = None, +) -> str: + """Thin wrapper around models.next_task_id for testability.""" + return models.next_task_id(conn, project_id, category=category) + + def _is_permission_blocked(item: dict) -> bool: """Check if a follow-up item describes a permission/write failure.""" text = f"{item.get('title', '')} {item.get('brief', '')}".lower() @@ -139,7 +148,7 @@ def generate_followups( "options": ["rerun", "manual_task", "skip"], }) else: - new_id = models.next_task_id(conn, project_id) + new_id = _next_task_id(conn, project_id, category=task.get("category")) brief_dict = {"source": f"followup:{task_id}"} if item.get("type"): brief_dict["route_type"] = item["type"] @@ -152,6 +161,7 @@ def generate_followups( priority=item.get("priority", 5), parent_task_id=task_id, brief=brief_dict, + category=task.get("category"), ) created.append(t) @@ -191,7 +201,7 @@ def resolve_pending_action( return None if choice == "manual_task": - new_id = models.next_task_id(conn, project_id) + new_id = _next_task_id(conn, project_id, category=task.get("category")) brief_dict = {"source": f"followup:{task_id}", "task_type": "manual_escalation"} if item.get("type"): brief_dict["route_type"] = item["type"] @@ -203,6 +213,7 @@ def resolve_pending_action( priority=item.get("priority", 5), parent_task_id=task_id, brief=brief_dict, + category=task.get("category"), ) if choice == "rerun": diff --git a/tests/test_api.py b/tests/test_api.py index ba194a9..631b092 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1071,3 +1071,65 @@ def test_task_full_project_deploy_command_none_when_not_set(client): data = r.json() assert "project_deploy_command" in data assert data["project_deploy_command"] is None + + +# --------------------------------------------------------------------------- +# KIN-067 — PATCH obsidian_vault_path + sync/obsidian не возвращает 400 +# --------------------------------------------------------------------------- + +def test_patch_project_obsidian_vault_path_persisted_via_sql(client): + """PATCH с obsidian_vault_path сохраняется в БД — прямой SQL.""" + r = client.patch("/api/projects/p1", json={"obsidian_vault_path": "/tmp/vault"}) + assert r.status_code == 200 + + from core.db import init_db + conn = init_db(api_module.DB_PATH) + row = conn.execute("SELECT obsidian_vault_path FROM projects WHERE id = 'p1'").fetchone() + conn.close() + assert row is not None + assert row[0] == "/tmp/vault" + + +def test_patch_project_obsidian_vault_path_returned_in_response(client): + """PATCH возвращает обновлённый obsidian_vault_path в ответе.""" + r = client.patch("/api/projects/p1", json={"obsidian_vault_path": "/my/vault"}) + assert r.status_code == 200 + assert r.json()["obsidian_vault_path"] == "/my/vault" + + +def test_sync_obsidian_without_vault_path_returns_400(client): + """POST sync/obsidian без сохранённого vault_path → 400 Bad Request.""" + r = client.post("/api/projects/p1/sync/obsidian") + assert r.status_code == 400 + + +def test_sync_obsidian_after_patch_vault_path_not_400(client, tmp_path): + """Сценарий бага KIN-067: сначала PATCH vault_path, затем sync → не 400. + + Раньше runSync() вызывал sync/obsidian без предварительного сохранения пути, + что приводило к 400. После фикса PATCH вызывается первым. + """ + vault = tmp_path / "vault" + vault.mkdir() + + # Шаг 1: сохранить vault_path через PATCH (как теперь делает runSync) + r = client.patch("/api/projects/p1", json={"obsidian_vault_path": str(vault)}) + assert r.status_code == 200 + + # Шаг 2: запустить синхронизацию — не должно вернуть 400 + r = client.post("/api/projects/p1/sync/obsidian") + assert r.status_code != 400, f"Ожидался не 400, получен {r.status_code}: {r.text}" + assert r.status_code == 200 + + +def test_sync_obsidian_after_patch_returns_sync_result_fields(client, tmp_path): + """После PATCH vault_path синхронизация возвращает поля exported_decisions и tasks_updated.""" + vault = tmp_path / "vault" + vault.mkdir() + + client.patch("/api/projects/p1", json={"obsidian_vault_path": str(vault)}) + r = client.post("/api/projects/p1/sync/obsidian") + assert r.status_code == 200 + data = r.json() + assert "exported_decisions" in data + assert "tasks_updated" in data diff --git a/web/frontend/src/components/Badge.vue b/web/frontend/src/components/Badge.vue index f321109..7357e27 100644 --- a/web/frontend/src/components/Badge.vue +++ b/web/frontend/src/components/Badge.vue @@ -9,6 +9,11 @@ const colors: Record = { gray: 'bg-gray-800/50 text-gray-400 border-gray-700', purple: 'bg-purple-900/50 text-purple-400 border-purple-800', orange: 'bg-orange-900/50 text-orange-400 border-orange-800', + indigo: 'bg-indigo-900/50 text-indigo-400 border-indigo-800', + cyan: 'bg-cyan-900/50 text-cyan-400 border-cyan-800', + pink: 'bg-pink-900/50 text-pink-400 border-pink-800', + rose: 'bg-rose-900/50 text-rose-400 border-rose-800', + teal: 'bg-teal-900/50 text-teal-400 border-teal-800', } diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index dd0811f..fb3f5c1 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -26,6 +26,7 @@ function initStatusFilter(): string[] { } const selectedStatuses = ref(initStatusFilter()) +const selectedCategory = ref('') function toggleStatus(s: string) { const idx = selectedStatuses.value.indexOf(s) @@ -119,9 +120,9 @@ async function applyAudit() { // Add task modal const TASK_CATEGORIES = ['SEC', 'UI', 'API', 'INFRA', 'BIZ', 'DB', 'ARCH', 'TEST', 'PERF', 'DOCS', 'FIX', 'OBS'] const CATEGORY_COLORS: Record = { - SEC: 'red', UI: 'blue', API: 'green', INFRA: 'orange', BIZ: 'purple', - DB: 'yellow', ARCH: 'gray', TEST: 'purple', PERF: 'orange', DOCS: 'gray', - FIX: 'red', OBS: 'blue', + SEC: 'red', UI: 'purple', API: 'blue', INFRA: 'orange', BIZ: 'green', + DB: 'yellow', ARCH: 'indigo', TEST: 'cyan', PERF: 'pink', DOCS: 'gray', + FIX: 'rose', OBS: 'teal', } const showAddTask = ref(false) const taskForm = ref({ title: '', priority: 5, route_type: '', category: '' }) @@ -150,10 +151,17 @@ watch(selectedStatuses, (val) => { onMounted(async () => { await load(); loadMode(); loadAutocommit() }) +const taskCategories = computed(() => { + if (!project.value) return [] + const cats = new Set(project.value.tasks.map(t => t.category).filter(Boolean) as string[]) + return Array.from(cats).sort() +}) + const filteredTasks = computed(() => { if (!project.value) return [] let tasks = project.value.tasks if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status)) + if (selectedCategory.value) tasks = tasks.filter(t => t.category === selectedCategory.value) return tasks }) @@ -306,19 +314,20 @@ async function addDecision() {
-
-
- - -
+
+
+
+ + +