197 lines
8 KiB
Python
197 lines
8 KiB
Python
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)]))
|