Initial Hermes Kanban Mini App
This commit is contained in:
commit
4474880562
32 changed files with 4014 additions and 0 deletions
9
.env.example
Normal file
9
.env.example
Normal 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
41
.github/workflows/ci.yml
vendored
Normal 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
26
.gitignore
vendored
Normal 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
23
CONTRIBUTING.md
Normal 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
8
Dockerfile
Normal 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
21
LICENSE
Normal 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
160
README.md
Normal 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
21
SECURITY.md
Normal 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
0
backend/app/__init__.py
Normal file
197
backend/app/adapter.py
Normal file
197
backend/app/adapter.py
Normal 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
42
backend/app/auth.py
Normal 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
41
backend/app/config.py
Normal 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
134
backend/app/main.py
Normal 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
53
backend/app/models.py
Normal 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
23
backend/pyproject.toml
Normal 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"
|
||||
64
backend/tests/test_adapter.py
Normal file
64
backend/tests/test_adapter.py
Normal 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
165
backend/tests/test_api.py
Normal 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
22
docker-compose.yml
Normal 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
1
frontend/index.html
Normal 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
2525
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
1
frontend/package.json
Normal file
1
frontend/package.json
Normal 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
186
frontend/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
frontend/src/__tests__/App.test.tsx
Normal file
76
frontend/src/__tests__/App.test.tsx
Normal 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
31
frontend/src/api.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
1
frontend/src/setupTests.ts
Normal file
1
frontend/src/setupTests.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
45
frontend/src/styles.css
Normal file
45
frontend/src/styles.css
Normal 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
31
frontend/src/telegram.ts
Normal 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
18
frontend/src/types.ts
Normal 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
3
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.css'
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal 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
15
frontend/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue