Initial Hermes Kanban Mini App

This commit is contained in:
Kisskin-Mister 2026-07-09 17:19:33 +03:00
commit 4474880562
32 changed files with 4014 additions and 0 deletions

9
.env.example Normal file
View file

@ -0,0 +1,9 @@
HERMES_BIN=hermes
HERMES_HOME=/home/your-user/.hermes
HERMES_KANBAN_BOARD=default
KANBAN_UI_AUTH_MODE=dev
TELEGRAM_BOT_TOKEN=
TELEGRAM_ALLOWED_USER_IDS=
KANBAN_UI_ALLOWED_ORIGINS=http://localhost:5173
KANBAN_UI_COMMAND_TIMEOUT_SECONDS=30
VITE_API_BASE_URL=http://localhost:8000

41
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install backend
run: |
python -m pip install -U pip
pip install -e backend[dev]
- name: Backend tests
run: pytest backend/tests -q
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend
working-directory: frontend
run: npm ci
- name: Frontend tests
working-directory: frontend
run: npm test
- name: Frontend build
working-directory: frontend
run: npm run build

26
.gitignore vendored Normal file
View file

@ -0,0 +1,26 @@
# Runtime secrets: never commit real values
.env
.env.*
!.env.example
# Python
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.mypy_cache/
*.egg-info/
# Node
node_modules/
frontend/dist/
frontend/.vite/
coverage/
*.tsbuildinfo
# Local/editor
.DS_Store
.idea/
.vscode/
*.log

23
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,23 @@
# Contributing
Thanks for helping improve Hermes Kanban Mini App.
## Development
1. Fork or clone the repository.
2. Copy `.env.example` to `.env` and use local/dev values only.
3. Run backend tests with `cd backend && ../.venv/bin/pytest` or create your own venv.
4. Run frontend tests with `cd frontend && npm test`.
5. Run `npm run build` before opening a PR.
## Pull requests
- Keep changes focused.
- Add or update tests for behavior changes.
- Do not commit `.env`, bot tokens, chat IDs, personal hostnames, or private board data.
- Prefer fixed Hermes Kanban CLI actions over generic command execution.
## Code style
- Backend: typed Python, FastAPI, Pydantic models for request validation.
- Frontend: React + TypeScript, mobile-first UI, no heavy Telegram SDK lock-in.

8
Dockerfile Normal file
View file

@ -0,0 +1,8 @@
FROM python:3.11-slim AS backend
WORKDIR /app
COPY backend/pyproject.toml /app/backend/pyproject.toml
COPY backend/app /app/backend/app
RUN pip install --no-cache-dir -e /app/backend
ENV KANBAN_UI_AUTH_MODE=telegram
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--app-dir", "backend", "--host", "0.0.0.0", "--port", "8000"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hermes Kanban Mini App contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

160
README.md Normal file
View file

@ -0,0 +1,160 @@
# Hermes Kanban Mini App
A community-ready Telegram Mini App and browser dashboard for viewing and operating Hermes Agent Kanban boards from mobile or desktop.
Status: local MVP. The repository is prepared for a future public GitHub push, but no remote is created by this task.
## What it does
- Shows Hermes Kanban columns: triage, todo, ready, running, blocked, done, archived.
- Opens task details with body, events, runs and log tail.
- Creates tasks with assignee, priority, workspace kind, goal mode and runtime options.
- Adds comments and runs common actions: assign, promote, block, unblock, archive, dispatch.
- Supports Telegram Mini App theme variables and server-side Telegram `initData` validation.
## Architecture
```text
Telegram Mini App / Browser
|
| HTTPS in production, Vite proxy in dev
v
React + TypeScript frontend
|
| /api/* with optional X-Telegram-Init-Data
v
FastAPI backend
|
| fixed subprocess argv, shell=False
v
hermes kanban CLI
|
v
Hermes Kanban board
```
Screenshot placeholder: run the frontend locally and replace this section with screenshots before publishing the GitHub repository.
## Prerequisites
- Hermes Agent installed with Kanban enabled.
- Python 3.11+.
- Node.js 20+ (tested with Node 24).
- Telegram bot from BotFather for Mini App production mode.
- HTTPS public URL for Telegram Mini App production usage.
## Local development
```bash
git clone <your-future-repo-url> hermes-kanban-miniapp
cd hermes-kanban-miniapp
cp .env.example .env
```
Backend:
```bash
python3 -m venv .venv
. .venv/bin/activate
pip install -e backend[dev]
uvicorn app.main:app --app-dir backend --reload --host 127.0.0.1 --port 8000
```
Frontend:
```bash
cd frontend
npm install
npm run dev
```
Open http://localhost:5173. In `KANBAN_UI_AUTH_MODE=dev` or `none`, the browser works without Telegram.
## Environment variables
See `.env.example`.
Important settings:
- `HERMES_BIN`: Hermes executable, default `hermes`.
- `HERMES_HOME`: Hermes home directory for the target profile.
- `HERMES_KANBAN_BOARD`: default board slug.
- `KANBAN_UI_AUTH_MODE`: `telegram`, `dev` or `none`.
- `TELEGRAM_BOT_TOKEN`: bot token used only by backend for `initData` validation.
- `TELEGRAM_ALLOWED_USER_IDS`: optional comma-separated Telegram user id allowlist.
- `KANBAN_UI_ALLOWED_ORIGINS`: CORS allowlist.
- `VITE_API_BASE_URL`: frontend API base URL for production builds.
## Telegram Mini App setup
1. Open BotFather in Telegram.
2. Create or choose a bot.
3. Use `/setdomain` and set the HTTPS domain that serves this app.
4. Use `/setmenubutton` or create a Web App button and point it at your HTTPS frontend URL.
5. On the backend, set:
- `KANBAN_UI_AUTH_MODE=telegram`
- `TELEGRAM_BOT_TOKEN=<your bot token>`
- optionally `TELEGRAM_ALLOWED_USER_IDS=123,456`
6. Never put the bot token into frontend env vars or committed files.
## Production deployment notes
Telegram Mini Apps require HTTPS. Put the frontend and backend behind a reverse proxy such as Caddy, Nginx, Traefik or Cloudflare Tunnel. Configure CORS so the backend only accepts your frontend origin.
Systemd example:
```ini
[Unit]
Description=Hermes Kanban Mini App API
After=network.target
[Service]
WorkingDirectory=/opt/hermes-kanban-miniapp
EnvironmentFile=/opt/hermes-kanban-miniapp/.env
ExecStart=/opt/hermes-kanban-miniapp/.venv/bin/uvicorn app.main:app --app-dir backend --host 127.0.0.1 --port 8000
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
Build frontend for static hosting:
```bash
cd frontend
npm ci
npm run build
```
## Security model
- Backend accepts only fixed API actions mapped to fixed Hermes Kanban CLI subcommands.
- Subprocess calls use argv lists and `shell=False`.
- Telegram `initData` is validated according to Telegram WebApp signing rules in `telegram` auth mode.
- Optional Telegram user allowlist rejects unknown users.
- CORS is restrictive and configured by env.
- `.env` is ignored by git; `.env.example` contains placeholders only.
## Known limitations
- The adapter prefers Hermes CLI JSON output. If a local Hermes version lacks JSON for a command, parsing is conservative and may return raw text for some endpoints.
- No drag-and-drop yet; actions are button-based.
- No SSE yet; running boards auto-refresh every 2.5 seconds, otherwise every 5 seconds.
- No GitHub remote is created until the owner/name are confirmed and auth is configured.
## Tests
Backend:
```bash
. .venv/bin/activate
pytest backend/tests -q
```
Frontend:
```bash
cd frontend
npm test
npm run build
```

21
SECURITY.md Normal file
View file

@ -0,0 +1,21 @@
# Security Policy
## Supported versions
This project is pre-1.0. Security fixes land on `main` until versioned releases exist.
## Reporting a vulnerability
Please do not open a public issue with exploit details. Contact the repository maintainers privately through the security advisory flow once the public GitHub repository exists.
## Security model
- The frontend never receives `TELEGRAM_BOT_TOKEN`.
- Telegram Mini App `initData` is validated server-side when `KANBAN_UI_AUTH_MODE=telegram`.
- `TELEGRAM_ALLOWED_USER_IDS` can restrict access to specific Telegram users.
- Backend mutations go through fixed Hermes Kanban CLI subcommands with `subprocess.run(argv, shell=False)`.
- `.env` and local logs are ignored by git.
## Deployment notes
Run behind HTTPS for Telegram Mini Apps, restrict CORS with `KANBAN_UI_ALLOWED_ORIGINS`, and run the service as a user that has only the Hermes permissions it needs.

0
backend/app/__init__.py Normal file
View file

197
backend/app/adapter.py Normal file
View file

@ -0,0 +1,197 @@
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)]))

42
backend/app/auth.py Normal file
View file

@ -0,0 +1,42 @@
import hashlib
import hmac
import json
import time
from urllib.parse import parse_qsl
class TelegramAuthError(ValueError):
pass
def validate_telegram_init_data(init_data: str, bot_token: str, max_age_seconds: int = 86400) -> dict:
if not bot_token:
raise TelegramAuthError("Telegram bot token is required")
pairs = dict(parse_qsl(init_data, keep_blank_values=True))
received_hash = pairs.pop("hash", None)
if not received_hash:
raise TelegramAuthError("Telegram hash is missing")
data_check_string = "\n".join(f"{key}={value}" for key, value in sorted(pairs.items()))
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
expected_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected_hash, received_hash):
raise TelegramAuthError("Telegram hash is invalid")
auth_date_raw = pairs.get("auth_date")
if auth_date_raw:
try:
auth_date = int(auth_date_raw)
except ValueError as exc:
raise TelegramAuthError("Telegram auth_date is invalid") from exc
if max_age_seconds > 0 and time.time() - auth_date > max_age_seconds:
raise TelegramAuthError("Telegram initData is expired")
user_raw = pairs.get("user")
if not user_raw:
raise TelegramAuthError("Telegram user is missing")
try:
user = json.loads(user_raw)
except json.JSONDecodeError as exc:
raise TelegramAuthError("Telegram user is invalid") from exc
return user

41
backend/app/config.py Normal file
View file

@ -0,0 +1,41 @@
from functools import lru_cache
from typing import Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True)
hermes_bin: str = Field(default="hermes", alias="HERMES_BIN")
hermes_home: str | None = Field(default=None, alias="HERMES_HOME")
default_board: str = Field(default="default", alias="HERMES_KANBAN_BOARD")
auth_mode: Literal["telegram", "none", "dev"] = Field(default="dev", alias="KANBAN_UI_AUTH_MODE")
telegram_bot_token: str | None = Field(default=None, alias="TELEGRAM_BOT_TOKEN")
telegram_allowed_user_ids: list[int] = Field(default_factory=list, alias="TELEGRAM_ALLOWED_USER_IDS")
allowed_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"], alias="KANBAN_UI_ALLOWED_ORIGINS")
command_timeout_seconds: int = Field(default=30, alias="KANBAN_UI_COMMAND_TIMEOUT_SECONDS")
@field_validator("telegram_allowed_user_ids", mode="before")
@classmethod
def parse_user_ids(cls, value):
if value is None or value == "":
return []
if isinstance(value, str):
return [int(part.strip()) for part in value.split(",") if part.strip()]
return value
@field_validator("allowed_origins", mode="before")
@classmethod
def parse_origins(cls, value):
if value is None or value == "":
return []
if isinstance(value, str):
return [part.strip() for part in value.split(",") if part.strip()]
return value
@lru_cache
def get_settings() -> Settings:
return Settings()

134
backend/app/main.py Normal file
View file

@ -0,0 +1,134 @@
from collections import defaultdict
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from .adapter import HermesCliKanbanAdapter, KanbanAdapter, sanitize_error
from .auth import TelegramAuthError, validate_telegram_init_data
from .config import Settings, get_settings
from .models import AssignPayload, BlockPayload, CommentPayload, STATUSES, TaskCreate
def group_tasks(tasks: list[dict]) -> dict[str, list[dict]]:
groups: dict[str, list[dict]] = {status: [] for status in STATUSES}
extra: dict[str, list[dict]] = defaultdict(list)
for task in tasks:
status = task.get("status") or "todo"
if status in groups:
groups[status].append(task)
else:
extra[status].append(task)
groups.update(extra)
return groups
def create_app(settings: Settings | None = None, adapter: KanbanAdapter | None = None) -> FastAPI:
settings = settings or get_settings()
adapter = adapter or HermesCliKanbanAdapter(settings)
app = FastAPI(title="Hermes Kanban Mini App API", version="0.1.0")
app.state.settings = settings
app.state.adapter = adapter
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins or [],
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["Content-Type", "X-Telegram-Init-Data"],
)
async def require_user(x_telegram_init_data: Annotated[str | None, Header()] = None):
if settings.auth_mode in {"none", "dev"}:
return {"id": 0, "dev": True}
if not x_telegram_init_data:
raise HTTPException(status_code=401, detail={"error": "auth_required", "message": "Telegram initData required"})
try:
user = validate_telegram_init_data(x_telegram_init_data, settings.telegram_bot_token or "")
except TelegramAuthError as exc:
raise HTTPException(status_code=401, detail={"error": "auth_invalid", "message": str(exc)}) from exc
allowed = settings.telegram_allowed_user_ids
if allowed and int(user.get("id", -1)) not in allowed:
raise HTTPException(status_code=403, detail={"error": "forbidden", "message": "Telegram user is not allowed"})
return user
def selected_board(board: str | None) -> str | None:
return board or settings.default_board
def safe_call(func, *args, **kwargs):
try:
return func(*args, **kwargs)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=502,
detail={"error": "adapter_error", "message": sanitize_error(str(exc))},
) from exc
@app.get("/api/health")
def health():
return {"status": "ok", "auth_mode": settings.auth_mode}
@app.get("/api/boards")
def boards(user=Depends(require_user)):
return {"boards": safe_call(adapter.boards)}
@app.get("/api/tasks")
def list_tasks(board: str | None = Query(default=None), user=Depends(require_user)):
tasks = safe_call(adapter.list_tasks, selected_board(board))
return {"tasks": tasks, "groups": group_tasks(tasks)}
@app.get("/api/tasks/{task_id}")
def show_task(task_id: str, board: str | None = Query(default=None), user=Depends(require_user)):
return {"task": safe_call(adapter.show_task, task_id, selected_board(board))}
@app.get("/api/tasks/{task_id}/events")
def events(task_id: str, board: str | None = Query(default=None), user=Depends(require_user)):
return {"events": safe_call(adapter.task_events, task_id, selected_board(board))}
@app.get("/api/tasks/{task_id}/runs")
def runs(task_id: str, board: str | None = Query(default=None), user=Depends(require_user)):
return {"runs": safe_call(adapter.task_runs, task_id, selected_board(board))}
@app.get("/api/tasks/{task_id}/log")
def log(task_id: str, board: str | None = Query(default=None), lines: int = 200, user=Depends(require_user)):
return safe_call(adapter.task_log, task_id, selected_board(board), lines)
@app.post("/api/tasks")
def create_task(payload: TaskCreate, board: str | None = Query(default=None), user=Depends(require_user)):
return {"task": safe_call(adapter.create_task, payload, selected_board(board))}
@app.post("/api/tasks/{task_id}/comment")
def comment(task_id: str, payload: CommentPayload, board: str | None = Query(default=None), user=Depends(require_user)):
return safe_call(adapter.comment, task_id, payload.body, selected_board(board))
@app.post("/api/tasks/{task_id}/assign")
def assign(task_id: str, payload: AssignPayload, board: str | None = Query(default=None), user=Depends(require_user)):
return safe_call(adapter.assign, task_id, payload.assignee, selected_board(board))
@app.post("/api/tasks/{task_id}/promote")
def promote(task_id: str, board: str | None = Query(default=None), user=Depends(require_user)):
return safe_call(adapter.promote, task_id, selected_board(board))
@app.post("/api/tasks/{task_id}/block")
def block(task_id: str, payload: BlockPayload, board: str | None = Query(default=None), user=Depends(require_user)):
return safe_call(adapter.block, task_id, payload.reason, selected_board(board))
@app.post("/api/tasks/{task_id}/unblock")
def unblock(task_id: str, board: str | None = Query(default=None), user=Depends(require_user)):
return safe_call(adapter.unblock, task_id, selected_board(board))
@app.post("/api/tasks/{task_id}/archive")
def archive(task_id: str, board: str | None = Query(default=None), user=Depends(require_user)):
return safe_call(adapter.archive, task_id, selected_board(board))
@app.post("/api/dispatch")
def dispatch(board: str | None = Query(default=None), user=Depends(require_user)):
return safe_call(adapter.dispatch, selected_board(board))
return app
app = create_app()

53
backend/app/models.py Normal file
View file

@ -0,0 +1,53 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
STATUSES = ("triage", "todo", "ready", "running", "blocked", "done", "archived")
WorkspaceKind = Literal["scratch", "dir", "worktree"]
class Task(BaseModel):
model_config = ConfigDict(extra="allow")
id: str
title: str = ""
status: str = "todo"
assignee: str | None = None
priority: int | None = None
body: str | None = None
age: str | None = None
created_at: int | None = None
started_at: int | None = None
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
body: str = ""
assignee: str | None = Field(default=None, max_length=80)
priority: int = Field(default=0, ge=-1000, le=1000)
workspace_kind: WorkspaceKind = "scratch"
workspace_path: str | None = None
goal_mode: bool = False
max_runtime_seconds: int | None = Field(default=None, ge=30, le=86400)
@field_validator("title")
@classmethod
def clean_title(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("title is required")
if "\x00" in value:
raise ValueError("title contains unsafe null byte")
return value
class CommentPayload(BaseModel):
body: str = Field(min_length=1, max_length=10000)
class AssignPayload(BaseModel):
assignee: str = Field(min_length=1, max_length=80)
class BlockPayload(BaseModel):
reason: str = Field(min_length=1, max_length=1000)

23
backend/pyproject.toml Normal file
View file

@ -0,0 +1,23 @@
[project]
name = "hermes-kanban-miniapp-backend"
version = "0.1.0"
description = "FastAPI backend for the Hermes Kanban Telegram Mini App"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.3.0",
"python-dotenv>=1.0.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"httpx>=0.27.0",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-q"

View file

@ -0,0 +1,64 @@
import subprocess
import pytest
from app.adapter import HermesCliKanbanAdapter, HermesCommandError, parse_tasks_payload
from app.config import Settings
from app.models import TaskCreate
def test_hermes_cli_adapter_uses_argv_list_and_no_shell(monkeypatch):
calls = []
def fake_run(argv, **kwargs):
calls.append((argv, kwargs))
return subprocess.CompletedProcess(argv, 0, stdout='{"tasks": []}', stderr="")
monkeypatch.setattr(subprocess, "run", fake_run)
adapter = HermesCliKanbanAdapter(Settings(hermes_bin="hermes"))
adapter.list_tasks(board="default")
argv, kwargs = calls[0]
assert argv[:3] == ["hermes", "kanban", "list"]
assert isinstance(argv, list)
assert kwargs.get("shell") is not True
def test_create_task_cli_uses_fixed_subcommand(monkeypatch):
calls = []
def fake_run(argv, **kwargs):
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout='{"id": "t_new"}', stderr="")
monkeypatch.setattr(subprocess, "run", fake_run)
adapter = HermesCliKanbanAdapter(Settings(hermes_bin="hermes"))
adapter.create_task(TaskCreate(title="Hello", assignee="developer", priority=5), board="default")
argv = calls[0]
assert argv[:4] == ["hermes", "kanban", "create", "Hello"]
assert "--assignee" in argv
assert "developer" in argv
def test_failed_command_raises_sanitized_error(monkeypatch):
def fake_run(argv, **kwargs):
return subprocess.CompletedProcess(argv, 1, stdout="", stderr="bad token secret")
monkeypatch.setattr(subprocess, "run", fake_run)
adapter = HermesCliKanbanAdapter(Settings(hermes_bin="hermes"))
with pytest.raises(HermesCommandError) as exc:
adapter.list_tasks()
assert "secret" not in str(exc.value).lower()
def test_parse_tasks_payload_accepts_json_list_and_normalizes():
tasks = parse_tasks_payload('[{"id":"t_1","title":"A","status":"ready"}]')
assert tasks[0]["id"] == "t_1"
assert tasks[0]["assignee"] is None
def test_parse_tasks_payload_accepts_cli_text_table():
text = "t_1 ready developer 10 Build thing\n"
tasks = parse_tasks_payload(text)
assert tasks[0]["id"] == "t_1"
assert tasks[0]["status"] == "ready"
assert tasks[0]["assignee"] == "developer"

165
backend/tests/test_api.py Normal file
View file

@ -0,0 +1,165 @@
import hashlib
import hmac
import json
import time
from urllib.parse import quote, urlencode
import pytest
from fastapi.testclient import TestClient
from app.auth import validate_telegram_init_data
from app.config import Settings
from app.main import create_app
def signed_init_data(bot_token: str, user_id: int = 123) -> str:
pairs = {
"query_id": "AAEAAAE",
"user": json.dumps({"id": user_id, "first_name": "Test"}, separators=(",", ":")),
"auth_date": str(int(time.time())),
}
data_check_string = "\n".join(f"{k}={v}" for k, v in sorted(pairs.items()))
secret_key = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
pairs["hash"] = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
return urlencode(pairs, quote_via=quote)
class FakeAdapter:
def __init__(self):
self.calls = []
self.tasks = [
{"id": "t_1", "title": "Ready task", "status": "ready", "assignee": "dev", "priority": 7},
{"id": "t_2", "title": "Blocked task", "status": "blocked", "assignee": "worker", "priority": 1},
]
def boards(self):
return [{"slug": "default", "current": True}]
def list_tasks(self, board=None):
self.calls.append(("list", board))
return self.tasks
def show_task(self, task_id, board=None):
return {"id": task_id, "title": "Ready task", "body": "Full body", "status": "ready", "comments": [], "events": [], "runs": []}
def task_events(self, task_id, board=None):
return [{"kind": "created"}]
def task_runs(self, task_id, board=None):
return [{"id": 1, "status": "completed"}]
def task_log(self, task_id, board=None, lines=200):
return {"task_id": task_id, "log": "", "missing": True}
def create_task(self, payload, board=None):
self.calls.append(("create", payload.title))
return {"id": "t_new", "title": payload.title, "status": "todo"}
def comment(self, task_id, body, board=None):
self.calls.append(("comment", task_id, body))
return {"ok": True}
def assign(self, task_id, assignee, board=None):
self.calls.append(("assign", task_id, assignee))
return {"ok": True}
def promote(self, task_id, board=None):
self.calls.append(("promote", task_id))
return {"ok": True}
def block(self, task_id, reason, board=None):
self.calls.append(("block", task_id, reason))
return {"ok": True}
def unblock(self, task_id, board=None):
self.calls.append(("unblock", task_id))
return {"ok": True}
def archive(self, task_id, board=None):
self.calls.append(("archive", task_id))
return {"ok": True}
def dispatch(self, board=None):
self.calls.append(("dispatch", board))
return {"ok": True}
@pytest.fixture
def fake_adapter():
return FakeAdapter()
@pytest.fixture
def client(fake_adapter):
app = create_app(Settings(auth_mode="none", allowed_origins=["http://localhost:5173"]), fake_adapter)
return TestClient(app)
def test_health_returns_ok(client):
assert client.get("/api/health").json()["status"] == "ok"
def test_telegram_init_data_accepts_correct_signature():
data = signed_init_data("token123", 123)
user = validate_telegram_init_data(data, "token123", max_age_seconds=3600)
assert user["id"] == 123
def test_telegram_init_data_rejects_wrong_hash():
data = signed_init_data("token123", 123).replace("hash=", "hash=bad")
with pytest.raises(ValueError):
validate_telegram_init_data(data, "token123", max_age_seconds=3600)
def test_allowed_user_filter_rejects_unauthorized(fake_adapter):
settings = Settings(auth_mode="telegram", telegram_bot_token="token123", telegram_allowed_user_ids=[999])
app = create_app(settings, fake_adapter)
response = TestClient(app).get("/api/tasks", headers={"X-Telegram-Init-Data": signed_init_data("token123", 123)})
assert response.status_code == 403
def test_task_list_returns_normalized_tasks_grouped_by_status(client):
data = client.get("/api/tasks").json()
assert data["groups"]["ready"][0]["id"] == "t_1"
assert data["groups"]["blocked"][0]["status"] == "blocked"
def test_create_task_validates_required_title(client):
response = client.post("/api/tasks", json={"title": " "})
assert response.status_code == 422
def test_create_task_accepts_safe_payload(client):
response = client.post("/api/tasks", json={"title": "New task", "body": "Body", "assignee": "developer", "priority": 3})
assert response.status_code == 200
assert response.json()["task"]["id"] == "t_new"
def test_action_endpoints_call_fixed_adapter_methods(client, fake_adapter):
assert client.post("/api/tasks/t_1/comment", json={"body": "hello"}).status_code == 200
assert client.post("/api/tasks/t_1/assign", json={"assignee": "worker"}).status_code == 200
assert client.post("/api/tasks/t_1/promote").status_code == 200
assert client.post("/api/tasks/t_1/block", json={"reason": "need input"}).status_code == 200
assert client.post("/api/tasks/t_1/unblock").status_code == 200
assert client.post("/api/tasks/t_1/archive").status_code == 200
assert ("comment", "t_1", "hello") in fake_adapter.calls
assert ("assign", "t_1", "worker") in fake_adapter.calls
assert ("promote", "t_1") in fake_adapter.calls
def test_log_endpoint_handles_missing_log_gracefully(client):
response = client.get("/api/tasks/t_1/log")
assert response.status_code == 200
assert response.json()["missing"] is True
def test_adapter_failure_returns_structured_error_without_secret(client, fake_adapter):
def broken(*args, **kwargs):
raise RuntimeError("failed with secret token value")
fake_adapter.list_tasks = broken
response = client.get("/api/tasks")
assert response.status_code == 502
body = response.json()["detail"]
assert body["error"] == "adapter_error"
assert "secret" not in body["message"].lower()

22
docker-compose.yml Normal file
View file

@ -0,0 +1,22 @@
services:
api:
build: .
env_file: .env
ports:
- "8000:8000"
volumes:
- ${HERMES_HOME:-/home/your-user/.hermes}:${HERMES_HOME:-/home/your-user/.hermes}:ro
restart: unless-stopped
frontend:
image: node:22-alpine
working_dir: /app/frontend
command: sh -c "npm ci && npm run dev -- --host 0.0.0.0"
environment:
VITE_API_BASE_URL: http://localhost:8000
volumes:
- .:/app
ports:
- "5173:5173"
depends_on:
- api

1
frontend/index.html Normal file
View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Hermes Kanban</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>

2525
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

1
frontend/package.json Normal file
View file

@ -0,0 +1 @@
{"scripts":{"dev":"vite --host 0.0.0.0","build":"tsc -b && vite build","preview":"vite preview --host 0.0.0.0","test":"vitest run --environment jsdom"},"dependencies":{"@vitejs/plugin-react":"latest","vite":"latest","typescript":"latest","react":"latest","react-dom":"latest","lucide-react":"latest"},"devDependencies":{"vitest":"latest","jsdom":"latest","@testing-library/react":"latest","@testing-library/jest-dom":"latest","@testing-library/user-event":"latest","@types/react":"latest","@types/react-dom":"latest"}}

186
frontend/src/App.tsx Normal file
View file

@ -0,0 +1,186 @@
import { useEffect, useMemo, useState } from 'react'
import { RefreshCw, Plus, Send } from 'lucide-react'
import { api } from './api'
import { applyTelegramTheme } from './telegram'
import type { Event, Run, Task, TaskGroups } from './types'
const STATUSES = ['triage', 'todo', 'ready', 'running', 'blocked', 'done', 'archived']
type Detail = {
task?: Task
events: Event[]
runs: Run[]
log: string
missingLog: boolean
}
function ErrorBanner({ message }: { message: string }) {
return <div className="error" role="alert">{message}</div>
}
function TaskCard({ task, onOpen }: { task: Task; onOpen: (task: Task) => void }) {
return (
<button className="task-card" onClick={() => onOpen(task)}>
<span className="task-id">{task.id}</span>
<strong>{task.title}</strong>
<span className="status-pill">status: {task.status}</span>
<span>{task.assignee || 'unassigned'}</span>
<span>priority {task.priority ?? 0}</span>
{task.age && <span>{task.age}</span>}
</button>
)
}
function CreateTask({ onCreated }: { onCreated: () => void }) {
const [open, setOpen] = useState(false)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [assignee, setAssignee] = useState('')
const [priority, setPriority] = useState(0)
const [workspaceKind, setWorkspaceKind] = useState('scratch')
const [goalMode, setGoalMode] = useState(false)
const [maxRuntime, setMaxRuntime] = useState('')
const [error, setError] = useState('')
async function submit() {
if (!title.trim()) {
setError('Title is required')
return
}
setError('')
await api.create({
title,
body,
assignee: assignee || null,
priority,
workspace_kind: workspaceKind,
goal_mode: goalMode,
max_runtime_seconds: maxRuntime ? Number(maxRuntime) : null,
})
setOpen(false)
setTitle('')
setBody('')
onCreated()
}
if (!open) return <button className="primary" onClick={() => setOpen(true)}><Plus size={16} />Create task</button>
return (
<div className="modal-card">
<h2>Create task</h2>
{error && <ErrorBanner message={error} />}
<input placeholder="Title" value={title} onChange={(event) => setTitle(event.target.value)} />
<textarea placeholder="Body" value={body} onChange={(event) => setBody(event.target.value)} />
<input placeholder="Assignee" value={assignee} onChange={(event) => setAssignee(event.target.value)} />
<input aria-label="Priority" type="number" value={priority} onChange={(event) => setPriority(Number(event.target.value))} />
<select value={workspaceKind} onChange={(event) => setWorkspaceKind(event.target.value)}>
<option value="scratch">scratch</option>
<option value="dir">dir</option>
<option value="worktree">worktree</option>
</select>
<label className="row"><input type="checkbox" checked={goalMode} onChange={(event) => setGoalMode(event.target.checked)} /> goal mode</label>
<input placeholder="Max runtime seconds" value={maxRuntime} onChange={(event) => setMaxRuntime(event.target.value)} />
<div className="row">
<button onClick={submit}>Create</button>
<button className="ghost" onClick={() => setOpen(false)}>Cancel</button>
</div>
</div>
)
}
function DetailDrawer({ selected, detail, onClose, onRefresh }: { selected: Task | null; detail: Detail | null; onClose: () => void; onRefresh: () => void }) {
const [comment, setComment] = useState('')
const [assign, setAssign] = useState('')
const [blockReason, setBlockReason] = useState('')
if (!selected) return null
const task = detail?.task || selected
async function addComment() {
if (!comment.trim()) return
await api.comment(selected!.id, comment)
setComment('')
onRefresh()
}
return (
<aside className="drawer">
<button className="close" onClick={onClose}>×</button>
<p className="task-id">{task.id}</p>
<h2>{task.title}</h2>
<p className="body">{task.body || 'No body'}</p>
<div className="actions">
<button onClick={() => api.action(task.id, 'promote').then(onRefresh)}>Promote</button>
<button onClick={() => api.action(task.id, 'unblock').then(onRefresh)}>Unblock</button>
<button onClick={() => api.action(task.id, 'archive').then(onRefresh)}>Archive</button>
</div>
<div className="row"><input placeholder="Assign to" value={assign} onChange={(event) => setAssign(event.target.value)} /><button onClick={() => api.assign(task.id, assign).then(onRefresh)}>Assign</button></div>
<div className="row"><input placeholder="Block reason" value={blockReason} onChange={(event) => setBlockReason(event.target.value)} /><button onClick={() => api.block(task.id, blockReason).then(onRefresh)}>Block</button></div>
<section><h3>Comments</h3><div className="row"><input placeholder="Comment" value={comment} onChange={(event) => setComment(event.target.value)} /><button onClick={addComment}><Send size={14} /></button></div></section>
<section><h3>Events</h3><pre>{JSON.stringify(detail?.events || [], null, 2)}</pre></section>
<section><h3>Runs</h3><pre>{JSON.stringify(detail?.runs || [], null, 2)}</pre></section>
<section><h3>Log</h3><button onClick={onRefresh}>Refresh log</button><pre>{detail?.missingLog ? 'No log yet' : detail?.log}</pre></section>
</aside>
)
}
export default function App() {
const [groups, setGroups] = useState<TaskGroups>({})
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<Task | null>(null)
const [detail, setDetail] = useState<Detail | null>(null)
const hasRunningTasks = useMemo(() => (groups.running || []).length > 0, [groups])
async function loadBoard() {
try {
const data = await api.tasks()
setGroups(data.groups)
setError('')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load board')
} finally {
setLoading(false)
}
}
async function openTask(task: Task) {
setSelected(task)
const [taskData, events, runs, log] = await Promise.all([api.task(task.id), api.events(task.id), api.runs(task.id), api.log(task.id)])
setDetail({ task: taskData.task, events: events.events, runs: runs.runs, log: log.log, missingLog: log.missing })
}
async function refreshSelected() {
await loadBoard()
if (selected) await openTask(selected)
}
useEffect(() => {
applyTelegramTheme()
loadBoard()
}, [])
useEffect(() => {
const interval = window.setInterval(loadBoard, hasRunningTasks ? 2500 : 5000)
return () => window.clearInterval(interval)
}, [hasRunningTasks])
return (
<main className="app-shell">
<header className="topbar">
<div><p className="eyebrow">Telegram Mini App</p><h1>Hermes Kanban</h1></div>
<div className="row"><CreateTask onCreated={loadBoard} /><button className="ghost" onClick={loadBoard}><RefreshCw size={16} />Refresh</button><button className="ghost" onClick={() => api.dispatch().then(loadBoard)}>Dispatch</button></div>
</header>
{error && <ErrorBanner message={error} />}
{loading ? <p>Loading</p> : (
<section className="board">
{STATUSES.map((status) => (
<div className="column" key={status}>
<h2>{status}</h2>
{(groups[status] || []).map((task) => <TaskCard key={task.id} task={task} onOpen={openTask} />)}
{(groups[status] || []).length === 0 && <p className="empty">No tasks</p>}
</div>
))}
</section>
)}
<DetailDrawer selected={selected} detail={detail} onClose={() => setSelected(null)} onRefresh={refreshSelected} />
</main>
)
}

View file

@ -0,0 +1,76 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'
import App from '../App'
const tasksResponse = {
tasks: [
{ id: 't_1', title: 'Build thing', status: 'ready', assignee: 'developer', priority: 5, age: '1m' },
],
groups: {
triage: [],
todo: [],
ready: [{ id: 't_1', title: 'Build thing', status: 'ready', assignee: 'developer', priority: 5 }],
running: [],
blocked: [],
done: [],
archived: [],
},
}
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (String(url).includes('/api/tasks/t_1/events')) return new Response(JSON.stringify({ events: [{ kind: 'created' }] }))
if (String(url).includes('/api/tasks/t_1/runs')) return new Response(JSON.stringify({ runs: [{ id: 1, status: 'completed' }] }))
if (String(url).includes('/api/tasks/t_1/log')) return new Response(JSON.stringify({ log: 'tail', missing: false }))
if (String(url).endsWith('/api/tasks/t_1')) return new Response(JSON.stringify({ task: { ...tasksResponse.tasks[0], body: 'Full body' } }))
if (String(url).includes('/api/tasks') && init?.method === 'POST') return new Response(JSON.stringify({ task: { id: 't_new', title: 'Created', status: 'todo' } }))
if (String(url).includes('/api/tasks')) return new Response(JSON.stringify(tasksResponse))
return new Response(JSON.stringify({ status: 'ok' }))
}))
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('App', () => {
it('renders board columns', async () => {
render(<App />)
expect(await screen.findByText('ready')).toBeInTheDocument()
expect(screen.getByText('blocked')).toBeInTheDocument()
})
it('renders task cards with title status and assignee', async () => {
render(<App />)
expect(await screen.findByText('Build thing')).toBeInTheDocument()
expect(screen.getByText('developer')).toBeInTheDocument()
})
it('opens detail drawer and shows body events and log sections', async () => {
render(<App />)
fireEvent.click(await screen.findByText('Build thing'))
expect(await screen.findByText('Full body')).toBeInTheDocument()
expect(screen.getByText('Events')).toBeInTheDocument()
expect(screen.getByText('Log')).toBeInTheDocument()
})
it('create form validates title', async () => {
render(<App />)
fireEvent.click(await screen.findByText('Create task'))
fireEvent.click(screen.getByText('Create'))
expect(await screen.findByText('Title is required')).toBeInTheDocument()
})
it('shows API error state', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ detail: { message: 'boom' } }), { status: 500 })))
render(<App />)
expect(await screen.findByText(/boom/)).toBeInTheDocument()
})
it('does not crash when Telegram WebApp is absent', async () => {
vi.stubGlobal('Telegram', undefined)
render(<App />)
await waitFor(() => expect(screen.getByText('Hermes Kanban')).toBeInTheDocument())
})
})

31
frontend/src/api.ts Normal file
View file

@ -0,0 +1,31 @@
import { getTelegramInitData } from './telegram'
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers)
headers.set('Content-Type', 'application/json')
const initData = getTelegramInitData()
if (initData) headers.set('X-Telegram-Init-Data', initData)
const response = await fetch(`${API_BASE}${path}`, { ...init, headers })
const data = await response.json().catch(() => ({}))
if (!response.ok) {
const message = data?.detail?.message || data?.detail || response.statusText || 'API request failed'
throw new Error(String(message))
}
return data as T
}
export const api = {
tasks: () => request<{ tasks: any[]; groups: Record<string, any[]> }>('/api/tasks'),
task: (id: string) => request<{ task: any }>(`/api/tasks/${id}`),
events: (id: string) => request<{ events: any[] }>(`/api/tasks/${id}/events`),
runs: (id: string) => request<{ runs: any[] }>(`/api/tasks/${id}/runs`),
log: (id: string) => request<{ log: string; missing: boolean }>(`/api/tasks/${id}/log`),
create: (payload: Record<string, unknown>) => request<{ task: any }>('/api/tasks', { method: 'POST', body: JSON.stringify(payload) }),
comment: (id: string, body: string) => request(`/api/tasks/${id}/comment`, { method: 'POST', body: JSON.stringify({ body }) }),
action: (id: string, action: 'promote' | 'unblock' | 'archive') => request(`/api/tasks/${id}/${action}`, { method: 'POST' }),
block: (id: string, reason: string) => request(`/api/tasks/${id}/block`, { method: 'POST', body: JSON.stringify({ reason }) }),
assign: (id: string, assignee: string) => request(`/api/tasks/${id}/assign`, { method: 'POST', body: JSON.stringify({ assignee }) }),
dispatch: () => request('/api/dispatch', { method: 'POST' }),
}

10
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'

45
frontend/src/styles.css Normal file
View file

@ -0,0 +1,45 @@
:root {
color-scheme: light dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--tg-bg-color, #10131a);
color: var(--tg-text-color, #f5f7fb);
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input, textarea, select { font: inherit; }
button { border: 0; border-radius: 12px; padding: .7rem .9rem; cursor: pointer; background: #242b38; color: inherit; display: inline-flex; align-items: center; gap: .4rem; }
button:hover { filter: brightness(1.12); }
input, textarea, select { width: 100%; border-radius: 12px; border: 1px solid #374151; padding: .75rem; background: #151a23; color: inherit; }
textarea { min-height: 110px; resize: vertical; }
pre { white-space: pre-wrap; word-break: break-word; background: #0b0e14; border: 1px solid #242b38; border-radius: 12px; padding: .8rem; max-height: 240px; overflow: auto; }
.app-shell { padding: 1rem; max-width: 1500px; margin: 0 auto; }
.topbar { display: flex; justify-content: space-between; gap: 1rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
h1 { margin: 0; font-size: clamp(1.5rem, 5vw, 2.4rem); }
h2, h3 { margin: .3rem 0 .8rem; }
.eyebrow { color: var(--tg-hint-color, #9aa4b2); margin: 0; text-transform: uppercase; letter-spacing: .12em; font-size: .75rem; }
.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
.primary { background: var(--tg-button-color, #3b82f6); color: var(--tg-button-text-color, white); }
.ghost { background: transparent; border: 1px solid #374151; }
.error { background: #431d23; color: #fecdd3; border: 1px solid #f43f5e; border-radius: 14px; padding: .8rem 1rem; margin: .8rem 0; }
.board { display: grid; grid-template-columns: repeat(7, minmax(220px, 1fr)); gap: .9rem; overflow-x: auto; padding-bottom: 1rem; }
.column { background: rgba(255,255,255,.045); border: 1px solid rgba(255,255,255,.08); border-radius: 18px; min-height: 50vh; padding: .9rem; }
.column h2 { text-transform: lowercase; font-size: 1rem; color: var(--tg-accent-text-color, #93c5fd); }
.task-card { width: 100%; display: grid; grid-template-columns: 1fr auto; text-align: left; gap: .35rem .5rem; margin-bottom: .75rem; border: 1px solid rgba(255,255,255,.08); background: #171c26; }
.task-card strong { grid-column: 1 / -1; }
.task-id, .empty { color: var(--tg-hint-color, #9aa4b2); font-size: .82rem; }
.status-pill { border-radius: 999px; background: #1d4ed8; padding: .1rem .5rem; font-size: .78rem; justify-self: end; }
.modal-card { position: fixed; z-index: 4; right: 1rem; top: 5rem; width: min(440px, calc(100vw - 2rem)); display: grid; gap: .6rem; padding: 1rem; border-radius: 20px; background: #111827; border: 1px solid #374151; box-shadow: 0 20px 80px rgba(0,0,0,.45); }
.drawer { position: fixed; inset: 0 0 0 auto; width: min(560px, 100vw); background: #0f1420; border-left: 1px solid #374151; padding: 1rem; overflow-y: auto; z-index: 3; box-shadow: -20px 0 80px rgba(0,0,0,.35); }
.close { float: right; border-radius: 50%; width: 40px; height: 40px; justify-content: center; }
.body { color: #d1d5db; line-height: 1.55; }
.actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }
@media (max-width: 800px) {
.app-shell { padding: .75rem; }
.board { grid-template-columns: repeat(7, 82vw); }
.topbar { align-items: stretch; }
.topbar .row, .topbar button { width: 100%; justify-content: center; }
}

31
frontend/src/telegram.ts Normal file
View file

@ -0,0 +1,31 @@
declare global {
interface Window {
Telegram?: {
WebApp?: {
ready?: () => void
expand?: () => void
initData?: string
colorScheme?: 'light' | 'dark'
themeParams?: Record<string, string>
}
}
}
}
export function getTelegramInitData(): string | undefined {
return window.Telegram?.WebApp?.initData || undefined
}
export function applyTelegramTheme(): void {
const webApp = window.Telegram?.WebApp
webApp?.ready?.()
webApp?.expand?.()
const theme = webApp?.themeParams || {}
const root = document.documentElement
Object.entries(theme).forEach(([key, value]) => {
if (typeof value === 'string') root.style.setProperty(`--tg-${key.replaceAll('_', '-')}`, value)
})
if (webApp?.colorScheme) root.dataset.theme = webApp.colorScheme
}
export {}

18
frontend/src/types.ts Normal file
View file

@ -0,0 +1,18 @@
export type KanbanStatus = 'triage' | 'todo' | 'ready' | 'running' | 'blocked' | 'done' | 'archived'
export type Task = {
id: string
title: string
status: KanbanStatus | string
assignee?: string | null
priority?: number | null
age?: string | null
body?: string | null
created_at?: number | null
started_at?: number | null
}
export type TaskGroups = Record<string, Task[]>
export type Event = Record<string, unknown> & { kind?: string }
export type Run = Record<string, unknown> & { id?: number | string; status?: string }

3
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare module '*.css'

21
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}

15
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
setupFiles: './src/setupTests.ts',
environment: 'jsdom',
},
server: {
proxy: {
'/api': 'http://127.0.0.1:8000',
},
},
})