hermes-kanban-miniapp/backend/app/main.py
kisskin ade88808fb
Some checks failed
CI / backend (push) Failing after 37s
CI / frontend (push) Failing after 21s
feat: M4 docker deployment - bridge + frontend containers
- packages/bridge/Dockerfile: multi-stage build (node:22-alpine)
- frontend/Dockerfile: multi-stage build + nginx:alpine
- frontend/nginx.conf: proxy /bridge to bridge service
- docker-compose.yml: bridge (3456) + frontend (8080)
- .dockerignore: exclude backend/node_modules/dist
- Fix vite proxy target to port 3456
- .env with bridge vars (AUTH_MODE=dev)
2026-07-10 14:58:20 +03:00

142 lines
6.3 KiB
Python

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))
if settings.frontend_dist_dir:
from pathlib import Path
from fastapi.staticfiles import StaticFiles
frontend_dir = Path(settings.frontend_dist_dir)
if frontend_dir.is_dir():
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
return app
app = create_app()