hermes-kanban-miniapp/backend/app/adapter.py

198 lines
8 KiB
Python
Raw Normal View History

2026-07-09 17:19:33 +03:00
import json
import os
import re
import subprocess
from abc import ABC, abstractmethod
from typing import Any
from .config import Settings
from .models import STATUSES, TaskCreate
TASK_ID_RE = re.compile(r"^t_[a-zA-Z0-9]+$")
SENSITIVE_RE = re.compile(r"(secret|token|password|api[_-]?key|bot[_-]?token)\s*[:=]\s*\S+|secret|token|password", re.IGNORECASE)
class HermesCommandError(RuntimeError):
def __init__(self, message: str, returncode: int | None = None):
super().__init__(sanitize_error(message))
self.returncode = returncode
def sanitize_error(message: str) -> str:
cleaned = SENSITIVE_RE.sub("[redacted]", message or "Hermes command failed")
return cleaned[:500]
def normalize_task(raw: dict[str, Any]) -> dict[str, Any]:
task_id = str(raw.get("id") or raw.get("task_id") or "")
return {
**raw,
"id": task_id,
"title": str(raw.get("title") or raw.get("summary") or task_id),
"status": str(raw.get("status") or "todo"),
"assignee": raw.get("assignee"),
"priority": raw.get("priority"),
}
def parse_json_or_none(text: str) -> Any | None:
try:
return json.loads(text)
except json.JSONDecodeError:
return None
def parse_tasks_payload(text: str) -> list[dict[str, Any]]:
data = parse_json_or_none(text)
if isinstance(data, dict):
tasks = data.get("tasks") or data.get("items") or []
if isinstance(tasks, dict):
flattened = []
for status, items in tasks.items():
for item in items or []:
flattened.append({"status": status, **item})
tasks = flattened
return [normalize_task(t) for t in tasks if isinstance(t, dict)]
if isinstance(data, list):
return [normalize_task(t) for t in data if isinstance(t, dict)]
tasks: list[dict[str, Any]] = []
for line in text.splitlines():
line = line.strip()
if not line or line.lower().startswith(("id ", "task ", "---")):
continue
parts = re.split(r"\s{2,}|\t+", line, maxsplit=4)
if len(parts) < 2:
parts = line.split(maxsplit=4)
if not parts or not TASK_ID_RE.match(parts[0]):
continue
status = parts[1] if len(parts) > 1 and parts[1] in STATUSES else "todo"
assignee = parts[2] if len(parts) > 2 else None
priority = None
title_index = 3
if len(parts) > 3:
try:
priority = int(parts[3])
title_index = 4
except ValueError:
title_index = 3
title = parts[title_index] if len(parts) > title_index else parts[0]
tasks.append(normalize_task({"id": parts[0], "status": status, "assignee": assignee, "priority": priority, "title": title}))
return tasks
def parse_object_payload(text: str) -> dict[str, Any]:
data = parse_json_or_none(text)
if isinstance(data, dict):
return data.get("task") if isinstance(data.get("task"), dict) else data
return {"raw": text}
class KanbanAdapter(ABC):
@abstractmethod
def boards(self) -> list[dict[str, Any]]: ...
@abstractmethod
def list_tasks(self, board: str | None = None) -> list[dict[str, Any]]: ...
@abstractmethod
def show_task(self, task_id: str, board: str | None = None) -> dict[str, Any]: ...
@abstractmethod
def task_events(self, task_id: str, board: str | None = None) -> list[dict[str, Any]]: ...
@abstractmethod
def task_runs(self, task_id: str, board: str | None = None) -> list[dict[str, Any]]: ...
@abstractmethod
def task_log(self, task_id: str, board: str | None = None, lines: int = 200) -> dict[str, Any]: ...
class HermesCliKanbanAdapter(KanbanAdapter):
def __init__(self, settings: Settings):
self.settings = settings
def _env(self) -> dict[str, str]:
env = os.environ.copy()
if self.settings.hermes_home:
env["HERMES_HOME"] = self.settings.hermes_home
return env
def _run(self, args: list[str]) -> str:
argv = [self.settings.hermes_bin, "kanban", *args]
result = subprocess.run(
argv,
shell=False,
capture_output=True,
text=True,
timeout=self.settings.command_timeout_seconds,
env=self._env(),
)
if result.returncode != 0:
raise HermesCommandError(result.stderr or result.stdout, result.returncode)
return result.stdout
def _board_args(self, board: str | None) -> list[str]:
return ["--board", board] if board else []
def boards(self) -> list[dict[str, Any]]:
return [{"slug": self.settings.default_board, "current": True}]
def list_tasks(self, board: str | None = None) -> list[dict[str, Any]]:
return parse_tasks_payload(self._run(["list", "--json", *self._board_args(board)]))
def show_task(self, task_id: str, board: str | None = None) -> dict[str, Any]:
return parse_object_payload(self._run(["show", task_id, "--json", *self._board_args(board)]))
def task_events(self, task_id: str, board: str | None = None) -> list[dict[str, Any]]:
task = self.show_task(task_id, board)
return task.get("events", []) if isinstance(task.get("events"), list) else []
def task_runs(self, task_id: str, board: str | None = None) -> list[dict[str, Any]]:
out = self._run(["runs", task_id, "--json", *self._board_args(board)])
data = parse_json_or_none(out)
if isinstance(data, dict):
return data.get("runs", []) if isinstance(data.get("runs"), list) else []
return data if isinstance(data, list) else []
def task_log(self, task_id: str, board: str | None = None, lines: int = 200) -> dict[str, Any]:
try:
out = self._run(["log", task_id, "--lines", str(lines), *self._board_args(board)])
return {"task_id": task_id, "log": out, "missing": False}
except HermesCommandError as exc:
if "not found" in str(exc).lower() or "no log" in str(exc).lower():
return {"task_id": task_id, "log": "", "missing": True}
raise
def create_task(self, payload: TaskCreate, board: str | None = None) -> dict[str, Any]:
args = ["create", payload.title]
if payload.assignee:
args += ["--assignee", payload.assignee]
if payload.body:
args += ["--body", payload.body]
args += ["--priority", str(payload.priority), "--workspace-kind", payload.workspace_kind]
if payload.workspace_path:
args += ["--workspace-path", payload.workspace_path]
if payload.goal_mode:
args += ["--goal-mode"]
if payload.max_runtime_seconds:
args += ["--max-runtime-seconds", str(payload.max_runtime_seconds)]
args += self._board_args(board)
return parse_object_payload(self._run(args))
def comment(self, task_id: str, body: str, board: str | None = None) -> dict[str, Any]:
return parse_object_payload(self._run(["comment", task_id, body, *self._board_args(board)]))
def assign(self, task_id: str, assignee: str, board: str | None = None) -> dict[str, Any]:
return parse_object_payload(self._run(["assign", task_id, assignee, *self._board_args(board)]))
def promote(self, task_id: str, board: str | None = None) -> dict[str, Any]:
return parse_object_payload(self._run(["promote", task_id, *self._board_args(board)]))
def block(self, task_id: str, reason: str, board: str | None = None) -> dict[str, Any]:
return parse_object_payload(self._run(["block", task_id, reason, *self._board_args(board)]))
def unblock(self, task_id: str, board: str | None = None) -> dict[str, Any]:
return parse_object_payload(self._run(["unblock", task_id, *self._board_args(board)]))
def archive(self, task_id: str, board: str | None = None) -> dict[str, Any]:
return parse_object_payload(self._run(["archive", task_id, *self._board_args(board)]))
def dispatch(self, board: str | None = None) -> dict[str, Any]:
return parse_object_payload(self._run(["dispatch", *self._board_args(board)]))