diff --git a/agents/prompts/pm.md b/agents/prompts/pm.md index 910cbdd..16c9a72 100644 --- a/agents/prompts/pm.md +++ b/agents/prompts/pm.md @@ -40,6 +40,25 @@ Set `completion_mode` based on the following rules (in priority order): - `research`, `new_project`, `security_audit` → `"review"` 3. Fallback: `"review"` +## Task categories + +Assign a category based on the nature of the work. Choose ONE from this list: + +| Code | Meaning | +|------|---------| +| SEC | Security, auth, permissions | +| UI | Frontend, styles, UX | +| API | Integrations, endpoints, external APIs | +| INFRA| Infrastructure, DevOps, deployment | +| BIZ | Business logic, workflows | +| DB | Database schema, migrations, queries | +| ARCH | Architecture decisions, refactoring | +| TEST | Tests, QA, coverage | +| PERF | Performance optimizations | +| DOCS | Documentation | +| FIX | Hotfixes, bug fixes | +| OBS | Monitoring, observability, logging | + ## Output format Return ONLY valid JSON (no markdown, no explanation): @@ -48,6 +67,7 @@ Return ONLY valid JSON (no markdown, no explanation): { "analysis": "Brief analysis of what needs to be done", "completion_mode": "auto_complete", + "category": "FIX", "pipeline": [ { "role": "debugger", diff --git a/cli/main.py b/cli/main.py index b801cf0..3f6da06 100644 --- a/cli/main.py +++ b/cli/main.py @@ -53,21 +53,6 @@ def _table(headers: list[str], rows: list[list[str]], min_width: int = 6): return "\n".join(lines) -def _auto_task_id(conn, project_id: str) -> str: - """Generate next task ID like PROJ-001.""" - prefix = project_id.upper() - existing = models.list_tasks(conn, project_id=project_id) - max_num = 0 - for t in existing: - tid = t["id"] - if tid.startswith(prefix + "-"): - try: - num = int(tid.split("-", 1)[1]) - max_num = max(max_num, num) - except ValueError: - pass - return f"{prefix}-{max_num + 1:03d}" - # =========================================================================== # Root group @@ -178,18 +163,28 @@ def task(): @click.argument("title") @click.option("--type", "route_type", type=click.Choice(["debug", "feature", "refactor", "hotfix"]), default=None) @click.option("--priority", type=int, default=5) +@click.option("--category", "-c", default=None, + help=f"Task category: {', '.join(models.TASK_CATEGORIES)}") @click.pass_context -def task_add(ctx, project_id, title, route_type, priority): - """Add a task to a project. ID is auto-generated (PROJ-001).""" +def task_add(ctx, project_id, title, route_type, priority, category): + """Add a task to a project. ID is auto-generated (PROJ-001 or PROJ-CAT-001).""" conn = ctx.obj["conn"] p = models.get_project(conn, project_id) if not p: click.echo(f"Project '{project_id}' not found.", err=True) raise SystemExit(1) - task_id = _auto_task_id(conn, project_id) + if category: + category = category.upper() + if category not in models.TASK_CATEGORIES: + click.echo( + f"Invalid category '{category}'. Must be one of: {', '.join(models.TASK_CATEGORIES)}", + err=True, + ) + raise SystemExit(1) + task_id = models.next_task_id(conn, project_id, category=category) brief = {"route_type": route_type} if route_type else None t = models.create_task(conn, task_id, project_id, title, - priority=priority, brief=brief) + priority=priority, brief=brief, category=category) click.echo(f"Created task: {t['id']} — {t['title']}") @@ -588,16 +583,28 @@ def run_task(ctx, task_id, dry_run, allow_write): # Save completion_mode from PM output to task (only if not already set by user) task_current = models.get_task(conn, task_id) + update_fields = {} if not task_current.get("execution_mode"): pm_completion_mode = models.validate_completion_mode( output.get("completion_mode", "review") ) - models.update_task(conn, task_id, execution_mode=pm_completion_mode) + update_fields["execution_mode"] = pm_completion_mode import logging logging.getLogger("kin").info( "PM set completion_mode=%s for task %s", pm_completion_mode, task_id ) + # Save category from PM output (only if task has no category yet) + if not task_current.get("category"): + pm_category = output.get("category") + if pm_category and isinstance(pm_category, str): + pm_category = pm_category.upper() + if pm_category in models.TASK_CATEGORIES: + update_fields["category"] = pm_category + + if update_fields: + models.update_task(conn, task_id, **update_fields) + click.echo(f"\nAnalysis: {analysis}") click.echo(f"Pipeline ({len(pipeline_steps)} steps):") for i, step in enumerate(pipeline_steps, 1): diff --git a/core/db.py b/core/db.py index 4aacfb7..6e4c769 100644 --- a/core/db.py +++ b/core/db.py @@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS projects ( forgejo_repo TEXT, language TEXT DEFAULT 'ru', execution_mode TEXT NOT NULL DEFAULT 'review', + deploy_command TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -44,6 +45,7 @@ CREATE TABLE IF NOT EXISTS tasks ( blocked_reason TEXT, dangerously_skipped BOOLEAN DEFAULT 0, revise_comment TEXT, + category TEXT DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -244,10 +246,18 @@ def _migrate(conn: sqlite3.Connection): conn.execute("ALTER TABLE tasks ADD COLUMN revise_comment TEXT") conn.commit() + if "category" not in task_cols: + conn.execute("ALTER TABLE tasks ADD COLUMN category TEXT DEFAULT NULL") + conn.commit() + if "obsidian_vault_path" not in proj_cols: conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT") conn.commit() + if "deploy_command" not in proj_cols: + conn.execute("ALTER TABLE projects ADD COLUMN deploy_command TEXT") + conn.commit() + # Migrate audit_log table (KIN-021) existing_tables = {r[0] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" diff --git a/core/followup.py b/core/followup.py index ed5d464..cb4c054 100644 --- a/core/followup.py +++ b/core/followup.py @@ -48,21 +48,6 @@ def _collect_pipeline_output(conn: sqlite3.Connection, task_id: str) -> str: return "\n".join(parts) -def _next_task_id(conn: sqlite3.Connection, project_id: str) -> str: - """Generate the next sequential task ID for a project.""" - prefix = project_id.upper() - existing = models.list_tasks(conn, project_id=project_id) - max_num = 0 - for t in existing: - tid = t["id"] - if tid.startswith(prefix + "-"): - try: - num = int(tid.split("-", 1)[1]) - max_num = max(max_num, num) - except ValueError: - pass - return f"{prefix}-{max_num + 1:03d}" - def generate_followups( conn: sqlite3.Connection, @@ -154,7 +139,7 @@ def generate_followups( "options": ["rerun", "manual_task", "skip"], }) else: - new_id = _next_task_id(conn, project_id) + new_id = models.next_task_id(conn, project_id) brief_dict = {"source": f"followup:{task_id}"} if item.get("type"): brief_dict["route_type"] = item["type"] @@ -206,7 +191,7 @@ def resolve_pending_action( return None if choice == "manual_task": - new_id = _next_task_id(conn, project_id) + new_id = models.next_task_id(conn, project_id) brief_dict = {"source": f"followup:{task_id}", "task_type": "manual_escalation"} if item.get("type"): brief_dict["route_type"] = item["type"] diff --git a/core/models.py b/core/models.py index 93d0db3..c536b9b 100644 --- a/core/models.py +++ b/core/models.py @@ -16,6 +16,11 @@ VALID_TASK_STATUSES = [ VALID_COMPLETION_MODES = {"auto_complete", "review"} +TASK_CATEGORIES = [ + "SEC", "UI", "API", "INFRA", "BIZ", "DB", + "ARCH", "TEST", "PERF", "DOCS", "FIX", "OBS", +] + def validate_completion_mode(value: str) -> str: """Validate completion mode from LLM output. Falls back to 'review' if invalid.""" @@ -132,6 +137,44 @@ def update_project(conn: sqlite3.Connection, id: str, **fields) -> dict: # Tasks # --------------------------------------------------------------------------- +def next_task_id( + conn: sqlite3.Connection, + project_id: str, + category: str | None = None, +) -> str: + """Generate next task ID. + + Without category: PROJ-001 (backward-compatible old format) + With category: PROJ-CAT-001 (new format, per-category counter) + """ + prefix = project_id.upper() + existing = list_tasks(conn, project_id=project_id) + + if category: + cat_prefix = f"{prefix}-{category}-" + max_num = 0 + for t in existing: + tid = t["id"] + if tid.startswith(cat_prefix): + try: + max_num = max(max_num, int(tid[len(cat_prefix):])) + except ValueError: + pass + return f"{prefix}-{category}-{max_num + 1:03d}" + else: + # Old format: global max across project (integers only, skip CAT-NNN) + max_num = 0 + for t in existing: + tid = t["id"] + if tid.startswith(prefix + "-"): + suffix = tid[len(prefix) + 1:] + try: + max_num = max(max_num, int(suffix)) + except ValueError: + pass + return f"{prefix}-{max_num + 1:03d}" + + def create_task( conn: sqlite3.Connection, id: str, @@ -145,16 +188,17 @@ def create_task( spec: dict | None = None, forgejo_issue_id: int | None = None, execution_mode: str | None = None, + category: str | None = None, ) -> dict: """Create a task linked to a project.""" conn.execute( """INSERT INTO tasks (id, project_id, title, status, priority, assigned_role, parent_task_id, brief, spec, forgejo_issue_id, - execution_mode) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + execution_mode, category) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (id, project_id, title, status, priority, assigned_role, parent_task_id, _json_encode(brief), _json_encode(spec), - forgejo_issue_id, execution_mode), + forgejo_issue_id, execution_mode, category), ) conn.commit() return get_task(conn, id) diff --git a/tests/test_api.py b/tests/test_api.py index 3de2c47..ba194a9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -939,3 +939,135 @@ def test_patch_task_title_and_brief_text_together(client): data = r.json() assert data["title"] == "Совместное" assert data["brief"]["text"] == "и описание" + + +# --------------------------------------------------------------------------- +# KIN-049 — Deploy: миграция, PATCH deploy_command, POST /deploy +# --------------------------------------------------------------------------- + +def test_deploy_command_column_exists_in_schema(client): + """Миграция: PRAGMA table_info(projects) подтверждает наличие deploy_command (decision #74).""" + from core.db import init_db + conn = init_db(api_module.DB_PATH) + cols = {row[1] for row in conn.execute("PRAGMA table_info(projects)").fetchall()} + conn.close() + assert "deploy_command" in cols + + +def test_patch_project_deploy_command_persisted_via_sql(client): + """PATCH с deploy_command сохраняется в БД — прямой SQL (decision #55).""" + client.patch("/api/projects/p1", json={"deploy_command": "echo hello"}) + + from core.db import init_db + conn = init_db(api_module.DB_PATH) + row = conn.execute("SELECT deploy_command FROM projects WHERE id = 'p1'").fetchone() + conn.close() + assert row is not None + assert row[0] == "echo hello" + + +def test_patch_project_deploy_command_returned_in_response(client): + """После PATCH ответ содержит обновлённый deploy_command.""" + r = client.patch("/api/projects/p1", json={"deploy_command": "git push origin main"}) + assert r.status_code == 200 + assert r.json()["deploy_command"] == "git push origin main" + + +def test_patch_project_deploy_command_empty_string_clears_to_null(client): + """PATCH с пустой строкой очищает deploy_command → NULL (decision #68).""" + client.patch("/api/projects/p1", json={"deploy_command": "echo hello"}) + client.patch("/api/projects/p1", json={"deploy_command": ""}) + + from core.db import init_db + conn = init_db(api_module.DB_PATH) + row = conn.execute("SELECT deploy_command FROM projects WHERE id = 'p1'").fetchone() + conn.close() + assert row[0] is None + + +def test_deploy_project_executes_command_returns_stdout(client): + """POST /deploy — команда echo → stdout присутствует в ответе.""" + from unittest.mock import patch, MagicMock + + client.patch("/api/projects/p1", json={"deploy_command": "echo deployed"}) + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "deployed\n" + mock_result.stderr = "" + + with patch("web.api.subprocess.run", return_value=mock_result): + r = client.post("/api/projects/p1/deploy") + + assert r.status_code == 200 + data = r.json() + assert data["success"] is True + assert data["exit_code"] == 0 + assert "deployed" in data["stdout"] + assert "duration_seconds" in data + + +def test_deploy_project_without_deploy_command_returns_400(client): + """POST /deploy для проекта без deploy_command → 400.""" + r = client.post("/api/projects/p1/deploy") + assert r.status_code == 400 + + +def test_deploy_project_not_found_returns_404(client): + """POST /deploy для несуществующего проекта → 404.""" + r = client.post("/api/projects/NOPE/deploy") + assert r.status_code == 404 + + +def test_deploy_project_failed_command_returns_success_false(client): + """POST /deploy — ненулевой exit_code → success=False (команда выполнилась, но упала).""" + from unittest.mock import patch, MagicMock + + client.patch("/api/projects/p1", json={"deploy_command": "exit 1"}) + + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stdout = "" + mock_result.stderr = "error occurred" + + with patch("web.api.subprocess.run", return_value=mock_result): + r = client.post("/api/projects/p1/deploy") + + assert r.status_code == 200 + data = r.json() + assert data["success"] is False + assert data["exit_code"] == 1 + assert "error occurred" in data["stderr"] + + +def test_deploy_project_timeout_returns_504(client): + """POST /deploy — timeout → 504.""" + from unittest.mock import patch + import subprocess + + client.patch("/api/projects/p1", json={"deploy_command": "sleep 100"}) + + with patch("web.api.subprocess.run", side_effect=subprocess.TimeoutExpired("sleep 100", 60)): + r = client.post("/api/projects/p1/deploy") + + assert r.status_code == 504 + + +def test_task_full_includes_project_deploy_command(client): + """GET /api/tasks/{id}/full включает project_deploy_command из таблицы projects.""" + client.patch("/api/projects/p1", json={"deploy_command": "git push"}) + + r = client.get("/api/tasks/P1-001/full") + assert r.status_code == 200 + data = r.json() + assert "project_deploy_command" in data + assert data["project_deploy_command"] == "git push" + + +def test_task_full_project_deploy_command_none_when_not_set(client): + """GET /api/tasks/{id}/full возвращает project_deploy_command=None когда не задана.""" + r = client.get("/api/tasks/P1-001/full") + assert r.status_code == 200 + data = r.json() + assert "project_deploy_command" in data + assert data["project_deploy_command"] is None diff --git a/web/api.py b/web/api.py index 4c9fa49..56ff9f7 100644 --- a/web/api.py +++ b/web/api.py @@ -20,7 +20,7 @@ from pydantic import BaseModel from core.db import init_db from core import models -from core.models import VALID_COMPLETION_MODES +from core.models import VALID_COMPLETION_MODES, TASK_CATEGORIES from agents.bootstrap import ( detect_tech_stack, detect_modules, extract_decisions_from_claude_md, find_vault_root, scan_obsidian, save_to_db, @@ -139,12 +139,13 @@ class ProjectPatch(BaseModel): execution_mode: str | None = None autocommit_enabled: bool | None = None obsidian_vault_path: str | None = None + deploy_command: str | None = None @app.patch("/api/projects/{project_id}") def patch_project(project_id: str, body: ProjectPatch): - if body.execution_mode is None and body.autocommit_enabled is None and body.obsidian_vault_path is None: - raise HTTPException(400, "Nothing to update. Provide execution_mode, autocommit_enabled, or obsidian_vault_path.") + if body.execution_mode is None and body.autocommit_enabled is None and body.obsidian_vault_path is None and body.deploy_command is None: + raise HTTPException(400, "Nothing to update. Provide execution_mode, autocommit_enabled, obsidian_vault_path, or deploy_command.") if body.execution_mode is not None and body.execution_mode not in VALID_EXECUTION_MODES: raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}") conn = get_conn() @@ -159,6 +160,9 @@ def patch_project(project_id: str, body: ProjectPatch): fields["autocommit_enabled"] = int(body.autocommit_enabled) if body.obsidian_vault_path is not None: fields["obsidian_vault_path"] = body.obsidian_vault_path + if body.deploy_command is not None: + # Empty string = sentinel for clearing (decision #68) + fields["deploy_command"] = None if body.deploy_command == "" else body.deploy_command models.update_project(conn, project_id, **fields) p = models.get_project(conn, project_id) conn.close() @@ -183,6 +187,46 @@ def sync_obsidian_endpoint(project_id: str): return result +@app.post("/api/projects/{project_id}/deploy") +def deploy_project(project_id: str): + """Execute deploy_command for a project. Returns stdout/stderr/exit_code. + + # WARNING: shell=True — deploy_command is admin-only, set in Settings by the project owner. + """ + import time + conn = get_conn() + p = models.get_project(conn, project_id) + conn.close() + if not p: + raise HTTPException(404, f"Project '{project_id}' not found") + deploy_command = p.get("deploy_command") + if not deploy_command: + raise HTTPException(400, "deploy_command not set for this project") + cwd = p.get("path") or None + start = time.monotonic() + try: + result = subprocess.run( + deploy_command, + shell=True, # WARNING: shell=True — command is admin-only + cwd=cwd, + capture_output=True, + text=True, + timeout=60, + ) + except subprocess.TimeoutExpired: + raise HTTPException(504, "Deploy command timed out after 60 seconds") + except Exception as e: + raise HTTPException(500, f"Deploy failed: {e}") + duration = round(time.monotonic() - start, 2) + return { + "success": result.returncode == 0, + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "duration_seconds": duration, + } + + @app.post("/api/projects") def create_project(body: ProjectCreate): conn = get_conn() @@ -216,6 +260,7 @@ class TaskCreate(BaseModel): title: str priority: int = 5 route_type: str | None = None + category: str | None = None @app.post("/api/tasks") @@ -225,21 +270,16 @@ def create_task(body: TaskCreate): if not p: conn.close() raise HTTPException(404, f"Project '{body.project_id}' not found") - # Auto-generate task ID - existing = models.list_tasks(conn, project_id=body.project_id) - prefix = body.project_id.upper() - max_num = 0 - for t in existing: - if t["id"].startswith(prefix + "-"): - try: - num = int(t["id"].split("-", 1)[1]) - max_num = max(max_num, num) - except ValueError: - pass - task_id = f"{prefix}-{max_num + 1:03d}" + category = None + if body.category: + category = body.category.upper() + if category not in TASK_CATEGORIES: + conn.close() + raise HTTPException(400, f"Invalid category '{category}'. Must be one of: {', '.join(TASK_CATEGORIES)}") + task_id = models.next_task_id(conn, body.project_id, category=category) brief = {"route_type": body.route_type} if body.route_type else None t = models.create_task(conn, task_id, body.project_id, body.title, - priority=body.priority, brief=brief) + priority=body.priority, brief=brief, category=category) conn.close() return t @@ -344,8 +384,10 @@ def get_task_full(task_id: str): decisions = models.get_decisions(conn, t["project_id"]) # Filter to decisions linked to this task task_decisions = [d for d in decisions if d.get("task_id") == task_id] + p = models.get_project(conn, t["project_id"]) + project_deploy_command = p.get("deploy_command") if p else None conn.close() - return {**t, "pipeline_steps": steps, "related_decisions": task_decisions} + return {**t, "pipeline_steps": steps, "related_decisions": task_decisions, "project_deploy_command": project_deploy_command} class TaskApprove(BaseModel): diff --git a/web/frontend/src/__tests__/filter-persistence.test.ts b/web/frontend/src/__tests__/filter-persistence.test.ts index 95114ee..85aa031 100644 --- a/web/frontend/src/__tests__/filter-persistence.test.ts +++ b/web/frontend/src/__tests__/filter-persistence.test.ts @@ -28,6 +28,7 @@ vi.mock('../api', () => ({ createTask: vi.fn(), patchTask: vi.fn(), patchProject: vi.fn(), + deployProject: vi.fn(), }, })) @@ -785,3 +786,126 @@ describe('KIN-015: TaskDetail — Edit button и форма редактиров expect(wrapper.find('input:not([type])').exists(), 'Форма должна закрыться после сохранения').toBe(false) }) }) + +// ───────────────────────────────────────────────────────────── +// KIN-049: TaskDetail — кнопка Deploy +// ───────────────────────────────────────────────────────────── + +describe('KIN-049: TaskDetail — кнопка Deploy', () => { + function makeDeployTask(status: string, deployCommand: string | null) { + return { + id: 'KIN-049', + project_id: 'KIN', + title: 'Deploy Task', + status, + priority: 3, + assigned_role: null, + parent_task_id: null, + brief: null, + spec: null, + execution_mode: null, + project_deploy_command: deployCommand, + created_at: '2024-01-01', + updated_at: '2024-01-01', + pipeline_steps: [], + related_decisions: [], + } + } + + it('Кнопка Deploy видна при status=done и project_deploy_command задан', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('done', 'git push origin main') as any) + const router = makeRouter() + await router.push('/task/KIN-049') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-049' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + expect(deployBtn?.exists(), 'Кнопка Deploy должна быть видна при done + deploy_command').toBe(true) + }) + + it('Кнопка Deploy скрыта при status=done но без project_deploy_command', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('done', null) as any) + const router = makeRouter() + await router.push('/task/KIN-049') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-049' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const hasDeployBtn = wrapper.findAll('button').some(b => b.text().includes('Deploy')) + expect(hasDeployBtn, 'Deploy не должна быть видна без deploy_command').toBe(false) + }) + + it('Кнопка Deploy скрыта при status=pending (даже с deploy_command)', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('pending', 'git push') as any) + const router = makeRouter() + await router.push('/task/KIN-049') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-049' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const hasDeployBtn = wrapper.findAll('button').some(b => b.text().includes('Deploy')) + expect(hasDeployBtn, 'Deploy не должна быть видна при статусе pending').toBe(false) + }) + + it('Кнопка Deploy скрыта при status=in_progress', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('in_progress', 'git push') as any) + const router = makeRouter() + await router.push('/task/KIN-049') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-049' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const hasDeployBtn = wrapper.findAll('button').some(b => b.text().includes('Deploy')) + expect(hasDeployBtn, 'Deploy не должна быть видна при статусе in_progress').toBe(false) + }) + + it('Кнопка Deploy скрыта при status=review', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('review', 'git push') as any) + const router = makeRouter() + await router.push('/task/KIN-049') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-049' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const hasDeployBtn = wrapper.findAll('button').some(b => b.text().includes('Deploy')) + expect(hasDeployBtn, 'Deploy не должна быть видна при статусе review').toBe(false) + }) + + it('Клик по Deploy вызывает api.deployProject с project_id задачи', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makeDeployTask('done', 'echo ok') as any) + vi.mocked(api.deployProject).mockResolvedValue({ + success: true, exit_code: 0, stdout: 'ok\n', stderr: '', duration_seconds: 0.1, + } as any) + + const router = makeRouter() + await router.push('/task/KIN-049') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-049' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + await deployBtn!.trigger('click') + await flushPromises() + + expect(api.deployProject).toHaveBeenCalledWith('KIN') + }) +}) diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 5040aae..6efc374 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -42,6 +42,7 @@ export interface Project { execution_mode: string | null autocommit_enabled: number | null obsidian_vault_path: string | null + deploy_command: string | null created_at: string total_tasks: number done_tasks: number @@ -76,6 +77,7 @@ export interface Task { execution_mode: string | null blocked_reason: string | null dangerously_skipped: number | null + category: string | null created_at: string updated_at: string } @@ -116,9 +118,18 @@ export interface PipelineStep { created_at: string } +export interface DeployResult { + success: boolean + exit_code: number + stdout: string + stderr: string + duration_seconds: number +} + export interface TaskFull extends Task { pipeline_steps: PipelineStep[] related_decisions: Decision[] + project_deploy_command: string | null } export interface PendingAction { @@ -161,7 +172,7 @@ export const api = { cost: (days = 7) => get(`/cost?days=${days}`), createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) => post('/projects', data), - createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string }) => + createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string; category?: string }) => post('/tasks', data), approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string; create_followups?: boolean }) => post<{ status: string; followup_tasks: Task[]; needs_decision: boolean; pending_actions: PendingAction[] }>(`/tasks/${id}/approve`, data || {}), @@ -181,8 +192,10 @@ export const api = { post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }), patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string }) => patch(`/tasks/${id}`, data), - patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; obsidian_vault_path?: string }) => + patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string }) => patch(`/projects/${id}`, data), + deployProject: (projectId: string) => + post(`/projects/${projectId}/deploy`, {}), syncObsidian: (projectId: string) => post(`/projects/${projectId}/sync/obsidian`, {}), deleteDecision: (projectId: string, decisionId: number) => diff --git a/web/frontend/src/views/SettingsView.vue b/web/frontend/src/views/SettingsView.vue index 319574a..e3b2dd1 100644 --- a/web/frontend/src/views/SettingsView.vue +++ b/web/frontend/src/views/SettingsView.vue @@ -4,9 +4,12 @@ import { api, type Project, type ObsidianSyncResult } from '../api' const projects = ref([]) const vaultPaths = ref>({}) +const deployCommands = ref>({}) const saving = ref>({}) +const savingDeploy = ref>({}) const syncing = ref>({}) const saveStatus = ref>({}) +const saveDeployStatus = ref>({}) const syncResults = ref>({}) const error = ref(null) @@ -15,6 +18,7 @@ onMounted(async () => { projects.value = await api.projects() for (const p of projects.value) { vaultPaths.value[p.id] = p.obsidian_vault_path ?? '' + deployCommands.value[p.id] = p.deploy_command ?? '' } } catch (e) { error.value = String(e) @@ -34,6 +38,19 @@ async function saveVaultPath(projectId: string) { } } +async function saveDeployCommand(projectId: string) { + savingDeploy.value[projectId] = true + saveDeployStatus.value[projectId] = '' + try { + await api.patchProject(projectId, { deploy_command: deployCommands.value[projectId] }) + saveDeployStatus.value[projectId] = 'Saved' + } catch (e) { + saveDeployStatus.value[projectId] = `Error: ${e}` + } finally { + savingDeploy.value[projectId] = false + } +} + async function runSync(projectId: string) { syncing.value[projectId] = true syncResults.value[projectId] = null @@ -70,13 +87,37 @@ async function runSync(projectId: string) { /> +
+ + +

Команда выполняется через shell в директории проекта. Настраивается только администратором.

+
+ +
+ + + {{ saveDeployStatus[project.id] }} + +
+
+ +
+ + +
+
+ + {{ deployResult.success ? '✓ Deploy succeeded' : '✗ Deploy failed' }} + + exit {{ deployResult.exit_code }} · {{ deployResult.duration_seconds }}s + +
+
{{ deployResult.stdout }}
+
{{ deployResult.stderr }}