kin: KIN-049 Кнопка Deploy на странице задачи после approve. Для каждого проекта настраивается deploy-команда (git push, scp, ssh restart). В Settings проекта.
This commit is contained in:
parent
860ef3f6c9
commit
d50bd703ae
11 changed files with 517 additions and 61 deletions
|
|
@ -40,6 +40,25 @@ Set `completion_mode` based on the following rules (in priority order):
|
||||||
- `research`, `new_project`, `security_audit` → `"review"`
|
- `research`, `new_project`, `security_audit` → `"review"`
|
||||||
3. Fallback: `"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
|
## Output format
|
||||||
|
|
||||||
Return ONLY valid JSON (no markdown, no explanation):
|
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",
|
"analysis": "Brief analysis of what needs to be done",
|
||||||
"completion_mode": "auto_complete",
|
"completion_mode": "auto_complete",
|
||||||
|
"category": "FIX",
|
||||||
"pipeline": [
|
"pipeline": [
|
||||||
{
|
{
|
||||||
"role": "debugger",
|
"role": "debugger",
|
||||||
|
|
|
||||||
47
cli/main.py
47
cli/main.py
|
|
@ -53,21 +53,6 @@ def _table(headers: list[str], rows: list[list[str]], min_width: int = 6):
|
||||||
return "\n".join(lines)
|
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
|
# Root group
|
||||||
|
|
@ -178,18 +163,28 @@ def task():
|
||||||
@click.argument("title")
|
@click.argument("title")
|
||||||
@click.option("--type", "route_type", type=click.Choice(["debug", "feature", "refactor", "hotfix"]), default=None)
|
@click.option("--type", "route_type", type=click.Choice(["debug", "feature", "refactor", "hotfix"]), default=None)
|
||||||
@click.option("--priority", type=int, default=5)
|
@click.option("--priority", type=int, default=5)
|
||||||
|
@click.option("--category", "-c", default=None,
|
||||||
|
help=f"Task category: {', '.join(models.TASK_CATEGORIES)}")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def task_add(ctx, project_id, title, route_type, priority):
|
def task_add(ctx, project_id, title, route_type, priority, category):
|
||||||
"""Add a task to a project. ID is auto-generated (PROJ-001)."""
|
"""Add a task to a project. ID is auto-generated (PROJ-001 or PROJ-CAT-001)."""
|
||||||
conn = ctx.obj["conn"]
|
conn = ctx.obj["conn"]
|
||||||
p = models.get_project(conn, project_id)
|
p = models.get_project(conn, project_id)
|
||||||
if not p:
|
if not p:
|
||||||
click.echo(f"Project '{project_id}' not found.", err=True)
|
click.echo(f"Project '{project_id}' not found.", err=True)
|
||||||
raise SystemExit(1)
|
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
|
brief = {"route_type": route_type} if route_type else None
|
||||||
t = models.create_task(conn, task_id, project_id, title,
|
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']}")
|
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)
|
# Save completion_mode from PM output to task (only if not already set by user)
|
||||||
task_current = models.get_task(conn, task_id)
|
task_current = models.get_task(conn, task_id)
|
||||||
|
update_fields = {}
|
||||||
if not task_current.get("execution_mode"):
|
if not task_current.get("execution_mode"):
|
||||||
pm_completion_mode = models.validate_completion_mode(
|
pm_completion_mode = models.validate_completion_mode(
|
||||||
output.get("completion_mode", "review")
|
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
|
import logging
|
||||||
logging.getLogger("kin").info(
|
logging.getLogger("kin").info(
|
||||||
"PM set completion_mode=%s for task %s", pm_completion_mode, task_id
|
"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"\nAnalysis: {analysis}")
|
||||||
click.echo(f"Pipeline ({len(pipeline_steps)} steps):")
|
click.echo(f"Pipeline ({len(pipeline_steps)} steps):")
|
||||||
for i, step in enumerate(pipeline_steps, 1):
|
for i, step in enumerate(pipeline_steps, 1):
|
||||||
|
|
|
||||||
10
core/db.py
10
core/db.py
|
|
@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||||
forgejo_repo TEXT,
|
forgejo_repo TEXT,
|
||||||
language TEXT DEFAULT 'ru',
|
language TEXT DEFAULT 'ru',
|
||||||
execution_mode TEXT NOT NULL DEFAULT 'review',
|
execution_mode TEXT NOT NULL DEFAULT 'review',
|
||||||
|
deploy_command TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -44,6 +45,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||||
blocked_reason TEXT,
|
blocked_reason TEXT,
|
||||||
dangerously_skipped BOOLEAN DEFAULT 0,
|
dangerously_skipped BOOLEAN DEFAULT 0,
|
||||||
revise_comment TEXT,
|
revise_comment TEXT,
|
||||||
|
category TEXT DEFAULT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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.execute("ALTER TABLE tasks ADD COLUMN revise_comment TEXT")
|
||||||
conn.commit()
|
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:
|
if "obsidian_vault_path" not in proj_cols:
|
||||||
conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT")
|
conn.execute("ALTER TABLE projects ADD COLUMN obsidian_vault_path TEXT")
|
||||||
conn.commit()
|
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)
|
# Migrate audit_log table (KIN-021)
|
||||||
existing_tables = {r[0] for r in conn.execute(
|
existing_tables = {r[0] for r in conn.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
|
|
||||||
|
|
@ -48,21 +48,6 @@ def _collect_pipeline_output(conn: sqlite3.Connection, task_id: str) -> str:
|
||||||
return "\n".join(parts)
|
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(
|
def generate_followups(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
|
|
@ -154,7 +139,7 @@ def generate_followups(
|
||||||
"options": ["rerun", "manual_task", "skip"],
|
"options": ["rerun", "manual_task", "skip"],
|
||||||
})
|
})
|
||||||
else:
|
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}"}
|
brief_dict = {"source": f"followup:{task_id}"}
|
||||||
if item.get("type"):
|
if item.get("type"):
|
||||||
brief_dict["route_type"] = item["type"]
|
brief_dict["route_type"] = item["type"]
|
||||||
|
|
@ -206,7 +191,7 @@ def resolve_pending_action(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if choice == "manual_task":
|
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"}
|
brief_dict = {"source": f"followup:{task_id}", "task_type": "manual_escalation"}
|
||||||
if item.get("type"):
|
if item.get("type"):
|
||||||
brief_dict["route_type"] = item["type"]
|
brief_dict["route_type"] = item["type"]
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ VALID_TASK_STATUSES = [
|
||||||
|
|
||||||
VALID_COMPLETION_MODES = {"auto_complete", "review"}
|
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:
|
def validate_completion_mode(value: str) -> str:
|
||||||
"""Validate completion mode from LLM output. Falls back to 'review' if invalid."""
|
"""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
|
# 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(
|
def create_task(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
id: str,
|
id: str,
|
||||||
|
|
@ -145,16 +188,17 @@ def create_task(
|
||||||
spec: dict | None = None,
|
spec: dict | None = None,
|
||||||
forgejo_issue_id: int | None = None,
|
forgejo_issue_id: int | None = None,
|
||||||
execution_mode: str | None = None,
|
execution_mode: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a task linked to a project."""
|
"""Create a task linked to a project."""
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO tasks (id, project_id, title, status, priority,
|
"""INSERT INTO tasks (id, project_id, title, status, priority,
|
||||||
assigned_role, parent_task_id, brief, spec, forgejo_issue_id,
|
assigned_role, parent_task_id, brief, spec, forgejo_issue_id,
|
||||||
execution_mode)
|
execution_mode, category)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(id, project_id, title, status, priority, assigned_role,
|
(id, project_id, title, status, priority, assigned_role,
|
||||||
parent_task_id, _json_encode(brief), _json_encode(spec),
|
parent_task_id, _json_encode(brief), _json_encode(spec),
|
||||||
forgejo_issue_id, execution_mode),
|
forgejo_issue_id, execution_mode, category),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return get_task(conn, id)
|
return get_task(conn, id)
|
||||||
|
|
|
||||||
|
|
@ -939,3 +939,135 @@ def test_patch_task_title_and_brief_text_together(client):
|
||||||
data = r.json()
|
data = r.json()
|
||||||
assert data["title"] == "Совместное"
|
assert data["title"] == "Совместное"
|
||||||
assert data["brief"]["text"] == "и описание"
|
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
|
||||||
|
|
|
||||||
76
web/api.py
76
web/api.py
|
|
@ -20,7 +20,7 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
from core.db import init_db
|
from core.db import init_db
|
||||||
from core import models
|
from core import models
|
||||||
from core.models import VALID_COMPLETION_MODES
|
from core.models import VALID_COMPLETION_MODES, TASK_CATEGORIES
|
||||||
from agents.bootstrap import (
|
from agents.bootstrap import (
|
||||||
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
||||||
find_vault_root, scan_obsidian, save_to_db,
|
find_vault_root, scan_obsidian, save_to_db,
|
||||||
|
|
@ -139,12 +139,13 @@ class ProjectPatch(BaseModel):
|
||||||
execution_mode: str | None = None
|
execution_mode: str | None = None
|
||||||
autocommit_enabled: bool | None = None
|
autocommit_enabled: bool | None = None
|
||||||
obsidian_vault_path: str | None = None
|
obsidian_vault_path: str | None = None
|
||||||
|
deploy_command: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@app.patch("/api/projects/{project_id}")
|
@app.patch("/api/projects/{project_id}")
|
||||||
def patch_project(project_id: str, body: ProjectPatch):
|
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:
|
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, or obsidian_vault_path.")
|
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:
|
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)}")
|
raise HTTPException(400, f"Invalid execution_mode '{body.execution_mode}'. Must be one of: {', '.join(VALID_EXECUTION_MODES)}")
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
|
|
@ -159,6 +160,9 @@ def patch_project(project_id: str, body: ProjectPatch):
|
||||||
fields["autocommit_enabled"] = int(body.autocommit_enabled)
|
fields["autocommit_enabled"] = int(body.autocommit_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:
|
||||||
|
# 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)
|
models.update_project(conn, project_id, **fields)
|
||||||
p = models.get_project(conn, project_id)
|
p = models.get_project(conn, project_id)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -183,6 +187,46 @@ def sync_obsidian_endpoint(project_id: str):
|
||||||
return result
|
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")
|
@app.post("/api/projects")
|
||||||
def create_project(body: ProjectCreate):
|
def create_project(body: ProjectCreate):
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
|
|
@ -216,6 +260,7 @@ class TaskCreate(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
priority: int = 5
|
priority: int = 5
|
||||||
route_type: str | None = None
|
route_type: str | None = None
|
||||||
|
category: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/tasks")
|
@app.post("/api/tasks")
|
||||||
|
|
@ -225,21 +270,16 @@ def create_task(body: TaskCreate):
|
||||||
if not p:
|
if not p:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(404, f"Project '{body.project_id}' not found")
|
raise HTTPException(404, f"Project '{body.project_id}' not found")
|
||||||
# Auto-generate task ID
|
category = None
|
||||||
existing = models.list_tasks(conn, project_id=body.project_id)
|
if body.category:
|
||||||
prefix = body.project_id.upper()
|
category = body.category.upper()
|
||||||
max_num = 0
|
if category not in TASK_CATEGORIES:
|
||||||
for t in existing:
|
conn.close()
|
||||||
if t["id"].startswith(prefix + "-"):
|
raise HTTPException(400, f"Invalid category '{category}'. Must be one of: {', '.join(TASK_CATEGORIES)}")
|
||||||
try:
|
task_id = models.next_task_id(conn, body.project_id, category=category)
|
||||||
num = int(t["id"].split("-", 1)[1])
|
|
||||||
max_num = max(max_num, num)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
task_id = f"{prefix}-{max_num + 1:03d}"
|
|
||||||
brief = {"route_type": body.route_type} if body.route_type else None
|
brief = {"route_type": body.route_type} if body.route_type else None
|
||||||
t = models.create_task(conn, task_id, body.project_id, body.title,
|
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()
|
conn.close()
|
||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
@ -344,8 +384,10 @@ def get_task_full(task_id: str):
|
||||||
decisions = models.get_decisions(conn, t["project_id"])
|
decisions = models.get_decisions(conn, t["project_id"])
|
||||||
# Filter to decisions linked to this task
|
# Filter to decisions linked to this task
|
||||||
task_decisions = [d for d in decisions if d.get("task_id") == task_id]
|
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()
|
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):
|
class TaskApprove(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ vi.mock('../api', () => ({
|
||||||
createTask: vi.fn(),
|
createTask: vi.fn(),
|
||||||
patchTask: vi.fn(),
|
patchTask: vi.fn(),
|
||||||
patchProject: 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)
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export interface Project {
|
||||||
execution_mode: string | null
|
execution_mode: string | null
|
||||||
autocommit_enabled: number | null
|
autocommit_enabled: number | null
|
||||||
obsidian_vault_path: string | null
|
obsidian_vault_path: string | null
|
||||||
|
deploy_command: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
total_tasks: number
|
total_tasks: number
|
||||||
done_tasks: number
|
done_tasks: number
|
||||||
|
|
@ -76,6 +77,7 @@ export interface Task {
|
||||||
execution_mode: string | null
|
execution_mode: string | null
|
||||||
blocked_reason: string | null
|
blocked_reason: string | null
|
||||||
dangerously_skipped: number | null
|
dangerously_skipped: number | null
|
||||||
|
category: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -116,9 +118,18 @@ export interface PipelineStep {
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeployResult {
|
||||||
|
success: boolean
|
||||||
|
exit_code: number
|
||||||
|
stdout: string
|
||||||
|
stderr: string
|
||||||
|
duration_seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskFull extends Task {
|
export interface TaskFull extends Task {
|
||||||
pipeline_steps: PipelineStep[]
|
pipeline_steps: PipelineStep[]
|
||||||
related_decisions: Decision[]
|
related_decisions: Decision[]
|
||||||
|
project_deploy_command: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingAction {
|
export interface PendingAction {
|
||||||
|
|
@ -161,7 +172,7 @@ export const api = {
|
||||||
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
|
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
|
||||||
createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) =>
|
createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) =>
|
||||||
post<Project>('/projects', data),
|
post<Project>('/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<Task>('/tasks', data),
|
post<Task>('/tasks', data),
|
||||||
approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string; create_followups?: boolean }) =>
|
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 || {}),
|
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 }),
|
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 }) =>
|
patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string }) =>
|
||||||
patch<Task>(`/tasks/${id}`, data),
|
patch<Task>(`/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<Project>(`/projects/${id}`, data),
|
patch<Project>(`/projects/${id}`, data),
|
||||||
|
deployProject: (projectId: string) =>
|
||||||
|
post<DeployResult>(`/projects/${projectId}/deploy`, {}),
|
||||||
syncObsidian: (projectId: string) =>
|
syncObsidian: (projectId: string) =>
|
||||||
post<ObsidianSyncResult>(`/projects/${projectId}/sync/obsidian`, {}),
|
post<ObsidianSyncResult>(`/projects/${projectId}/sync/obsidian`, {}),
|
||||||
deleteDecision: (projectId: string, decisionId: number) =>
|
deleteDecision: (projectId: string, decisionId: number) =>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,12 @@ import { api, type Project, type ObsidianSyncResult } from '../api'
|
||||||
|
|
||||||
const projects = ref<Project[]>([])
|
const projects = ref<Project[]>([])
|
||||||
const vaultPaths = ref<Record<string, string>>({})
|
const vaultPaths = ref<Record<string, string>>({})
|
||||||
|
const deployCommands = ref<Record<string, string>>({})
|
||||||
const saving = ref<Record<string, boolean>>({})
|
const saving = ref<Record<string, boolean>>({})
|
||||||
|
const savingDeploy = ref<Record<string, boolean>>({})
|
||||||
const syncing = ref<Record<string, boolean>>({})
|
const syncing = ref<Record<string, boolean>>({})
|
||||||
const saveStatus = ref<Record<string, string>>({})
|
const saveStatus = ref<Record<string, string>>({})
|
||||||
|
const saveDeployStatus = ref<Record<string, string>>({})
|
||||||
const syncResults = ref<Record<string, ObsidianSyncResult | null>>({})
|
const syncResults = ref<Record<string, ObsidianSyncResult | null>>({})
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -15,6 +18,7 @@ onMounted(async () => {
|
||||||
projects.value = await api.projects()
|
projects.value = await api.projects()
|
||||||
for (const p of projects.value) {
|
for (const p of projects.value) {
|
||||||
vaultPaths.value[p.id] = p.obsidian_vault_path ?? ''
|
vaultPaths.value[p.id] = p.obsidian_vault_path ?? ''
|
||||||
|
deployCommands.value[p.id] = p.deploy_command ?? ''
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = String(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) {
|
async function runSync(projectId: string) {
|
||||||
syncing.value[projectId] = true
|
syncing.value[projectId] = true
|
||||||
syncResults.value[projectId] = null
|
syncResults.value[projectId] = null
|
||||||
|
|
@ -70,13 +87,37 @@ async function runSync(projectId: string) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="block text-xs text-gray-400 mb-1">Deploy Command</label>
|
||||||
|
<input
|
||||||
|
v-model="deployCommands[project.id]"
|
||||||
|
type="text"
|
||||||
|
placeholder="git push origin main"
|
||||||
|
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-600 mt-1">Команда выполняется через shell в директории проекта. Настраивается только администратором.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 flex-wrap mb-3">
|
||||||
|
<button
|
||||||
|
@click="saveDeployCommand(project.id)"
|
||||||
|
:disabled="savingDeploy[project.id]"
|
||||||
|
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ savingDeploy[project.id] ? 'Saving…' : 'Save Deploy' }}
|
||||||
|
</button>
|
||||||
|
<span v-if="saveDeployStatus[project.id]" class="text-xs" :class="saveDeployStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
|
||||||
|
{{ saveDeployStatus[project.id] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<button
|
<button
|
||||||
@click="saveVaultPath(project.id)"
|
@click="saveVaultPath(project.id)"
|
||||||
:disabled="saving[project.id]"
|
:disabled="saving[project.id]"
|
||||||
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
|
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ saving[project.id] ? 'Saving…' : 'Save' }}
|
{{ saving[project.id] ? 'Saving…' : 'Save Vault' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api, type TaskFull, type PipelineStep, type PendingAction } from '../api'
|
import { api, type TaskFull, type PipelineStep, type PendingAction, type DeployResult } from '../api'
|
||||||
import Badge from '../components/Badge.vue'
|
import Badge from '../components/Badge.vue'
|
||||||
import Modal from '../components/Modal.vue'
|
import Modal from '../components/Modal.vue'
|
||||||
|
|
||||||
|
|
@ -262,6 +262,23 @@ async function changeStatus(newStatus: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deploy
|
||||||
|
const deploying = ref(false)
|
||||||
|
const deployResult = ref<DeployResult | null>(null)
|
||||||
|
|
||||||
|
async function runDeploy() {
|
||||||
|
if (!task.value) return
|
||||||
|
deploying.value = true
|
||||||
|
deployResult.value = null
|
||||||
|
try {
|
||||||
|
deployResult.value = await api.deployProject(task.value.project_id)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
deploying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Edit modal (pending tasks only)
|
// Edit modal (pending tasks only)
|
||||||
const showEdit = ref(false)
|
const showEdit = ref(false)
|
||||||
const editForm = ref({ title: '', briefText: '', priority: 5 })
|
const editForm = ref({ title: '', briefText: '', priority: 5 })
|
||||||
|
|
@ -488,6 +505,27 @@ async function saveEdit() {
|
||||||
<span v-if="resolvingManually" class="inline-block w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
<span v-if="resolvingManually" class="inline-block w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||||
{{ resolvingManually ? 'Сохраняем...' : '✓ Решить вручную' }}
|
{{ resolvingManually ? 'Сохраняем...' : '✓ Решить вручную' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="task.status === 'done' && task.project_deploy_command"
|
||||||
|
@click.stop="runDeploy"
|
||||||
|
:disabled="deploying"
|
||||||
|
class="px-4 py-2 text-sm bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50">
|
||||||
|
<span v-if="deploying" class="inline-block w-3 h-3 border-2 border-teal-400 border-t-transparent rounded-full animate-spin mr-1"></span>
|
||||||
|
{{ deploying ? 'Deploying...' : '🚀 Deploy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deploy result inline block -->
|
||||||
|
<div v-if="deployResult" class="mx-0 mt-2 p-3 rounded border text-xs font-mono"
|
||||||
|
:class="deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span :class="deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
|
||||||
|
{{ deployResult.success ? '✓ Deploy succeeded' : '✗ Deploy failed' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500">exit {{ deployResult.exit_code }} · {{ deployResult.duration_seconds }}s</span>
|
||||||
|
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs">✕</button>
|
||||||
|
</div>
|
||||||
|
<pre v-if="deployResult.stdout" class="whitespace-pre-wrap text-gray-300 max-h-40 overflow-y-auto">{{ deployResult.stdout }}</pre>
|
||||||
|
<pre v-if="deployResult.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-40 overflow-y-auto mt-1">{{ deployResult.stderr }}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Approve Modal -->
|
<!-- Approve Modal -->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue