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
76
web/api.py
76
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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue