""" Kanbante β€” Kanban project manager integrated with Gitea. FastAPI backend serving HTML + JSON API. 0.2.0 highlights (see CHANGELOG.md): - Optional HTTP Basic auth (KANBANTE_AUTH_USER / _PASSWORD) + CSRF double-submit protection on every POST. - Two-way sync: moving a card to "done" closes the Gitea issue, moving it back reopens it (KANBANTE_SYNC_GITEA=0 disables). - Repos are classified with a rolling activity window and Gitea's real "archived" flag, not a hardcoded year. - Board state is reconciled with Gitea on every board load, drag order is persisted, and Gitea failures render a proper 502 error page instead of a bare 500. """ import asyncio import base64 import logging import secrets from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone from pathlib import Path import httpx from fastapi import FastAPI, Form, Request from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader, select_autoescape from starlette.middleware.base import BaseHTTPMiddleware from app.config import APP_VERSION, get_settings from app.gitea_client import GiteaClient from app.models import ( KANBAN_COLUMNS, column_entry_times, get_board_state, get_events, get_metrics, get_notes, get_stats, init_db, log_event, move_issue, ping_db, reconcile_board_state, set_column_order, set_notes, ) logging.basicConfig(level=logging.INFO) logger = logging.getLogger("kanbante") BASE = Path(__file__).resolve().parent.parent CSRF_COOKIE = "kanbante_csrf" COLUMN_LABELS = { "backlog": "πŸ“₯ Backlog", "todo": "πŸ“‹ Γ€ faire", "in_progress": "🚧 En cours", "review": "πŸ” RΓ©vision", "done": "βœ… TerminΓ©", } COLUMN_COLORS = { "backlog": "#6b7280", "todo": "#3b82f6", "in_progress": "#f59e0b", "review": "#8b5cf6", "done": "#10b981", } FOCUS_COLUMNS = ["in_progress", "review"] EVENT_ICONS = { "created": "βž•", "moved": "➑️", "closed": "βœ…", "reopened": "πŸ”", "commented": "πŸ’¬", } # ── App setup ────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): init_db() settings = get_settings() if not settings.auth_enabled: logger.warning( "Authentication is DISABLED (KANBANTE_AUTH_USER / " "KANBANTE_AUTH_PASSWORD not set). Anyone who can reach this " "service can read every repo visible to the Gitea token and " "modify boards. Set the credentials or put Kanbante behind " "an authenticating reverse proxy." ) if not settings.gitea_token: logger.warning( "GITEA_TOKEN is not set: only public Gitea content will be visible." ) yield await gitea.aclose() app = FastAPI(title="Kanbante", version=APP_VERSION, lifespan=lifespan) app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") gitea = GiteaClient() # ── Auth middleware ──────────────────────────────────────── class AuthMiddleware(BaseHTTPMiddleware): """HTTP Basic auth, enabled only when credentials are configured. /api/health stays public so Docker's HEALTHCHECK (liveness) works. When KANBANTE_REQUIRE_AUTH=1 and no credentials are configured, every other route answers 503: failing closed beats failing open. """ async def dispatch(self, request: Request, call_next): if request.url.path == "/api/health": return await call_next(request) settings = get_settings() if not settings.auth_enabled: if settings.require_auth: return JSONResponse( { "ok": False, "error": "Authentication required but " "KANBANTE_AUTH_USER / KANBANTE_AUTH_PASSWORD " "are not configured.", }, status_code=503, ) return await call_next(request) ok = False header = request.headers.get("authorization", "") if header.lower().startswith("basic "): try: decoded = base64.b64decode(header[6:]).decode("utf-8") except Exception: decoded = "" user, _, password = decoded.partition(":") ok = secrets.compare_digest( user, settings.auth_user ) and secrets.compare_digest(password, settings.auth_password) if not ok: return Response( "Authentication required", status_code=401, headers={"WWW-Authenticate": 'Basic realm="Kanbante"'}, ) return await call_next(request) app.add_middleware(AuthMiddleware) # ── Templates ────────────────────────────────────────────── _tpl_env = Environment( loader=FileSystemLoader(str(BASE / "app" / "templates")), autoescape=select_autoescape(["html"]), ) def render( request: Request, name: str, status_code: int = 200, **context ) -> HTMLResponse: """Render a Jinja2 template and manage the CSRF cookie. The CSRF token lives in an HttpOnly cookie; templates expose it in a meta tag / hidden fields (double-submit pattern) and POST handlers compare the submitted value with the cookie. """ token = request.cookies.get(CSRF_COOKIE) generated = token is None if generated: token = secrets.token_urlsafe(32) context.setdefault("csrf_token", token) context.setdefault("version", APP_VERSION) html = _tpl_env.get_template(name).render(**context) response = HTMLResponse(html, status_code=status_code) if generated: response.set_cookie(CSRF_COOKIE, token, samesite="lax", httponly=True) return response def _csrf_valid(request: Request, form_token: str) -> bool: cookie = request.cookies.get(CSRF_COOKIE, "") provided = form_token or request.headers.get("x-csrf-token", "") return bool(cookie) and bool(provided) and secrets.compare_digest(cookie, provided) def _csrf_error() -> JSONResponse: return JSONResponse( {"ok": False, "error": "Invalid or missing CSRF token"}, status_code=403 ) @app.exception_handler(httpx.HTTPError) async def gitea_error_handler(request: Request, exc: httpx.HTTPError): """Gitea failures become a clean 502, never a bare 500.""" logger.warning("Gitea error on %s: %s", request.url.path, exc) if request.url.path.startswith("/api/"): return JSONResponse( {"ok": False, "error": "Gitea is unreachable or returned an error"}, status_code=502, ) return render( request, "error.html", status_code=502, message="Kanbante n'a pas pu joindre Gitea. VΓ©rifie que l'instance " "est en ligne et que le jeton est valide, puis rΓ©essaie.", ) # ── Helpers ──────────────────────────────────────────────── def _parse_datetime(value: str | None) -> datetime | None: if not value: return None try: return datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: return None def classify_repos( repos: list[dict], now: datetime | None = None ) -> tuple[list[dict], list[dict], list[dict]]: """Split repos into (active, inactive, archived). "Archived" is Gitea's own flag. Among the rest, a repo is active if it was updated within the configured rolling window β€” no hardcoded calendar year. """ settings = get_settings() now = now or datetime.now(timezone.utc) cutoff = now - timedelta(days=settings.active_window_days) active, inactive, archived = [], [], [] for repo in repos: if repo.get("archived"): archived.append(repo) continue updated = _parse_datetime(repo.get("updated_at")) if updated is not None and updated >= cutoff: active.append(repo) else: inactive.append(repo) return active, inactive, archived def _is_issue(item: dict) -> bool: """Gitea's issues endpoint also returns PRs; they carry this key.""" return "pull_request" not in item def _merge_issues_with_board( issues: list[dict], board: dict[str, list[dict]] ) -> dict[str, list[dict]]: """Merge Gitea issues into kanban columns, honouring saved order.""" placement: dict[int, tuple[str, float]] = {} for col_name, cards in board.items(): for card in cards: placement[card["issue_number"]] = (col_name, card["position"]) columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS} for index, issue in enumerate(issues): col, pos = placement.get(issue["number"], ("backlog", None)) issue["_sort"] = (0, pos, index) if pos is not None else (1, 0.0, index) columns[col].append(issue) for col in columns.values(): col.sort(key=lambda i: i["_sort"]) return columns def _split_full_name(repo_full_name: str) -> tuple[str, str] | None: owner, sep, repo = repo_full_name.partition("/") if not sep or not owner or not repo or "/" in repo: return None return owner, repo def _parse_order(raw: str) -> list[int]: numbers = [] for chunk in (raw or "").split(","): chunk = chunk.strip() if chunk.isdigit(): numbers.append(int(chunk)) return numbers # ── Routes: Pages ───────────────────────────────────────── @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request): """Main dashboard β€” all projects at a glance.""" repos = await gitea.search_repos() active, inactive, archived = classify_repos(repos) return render( request, "dashboard.html", active_repos=active, inactive_repos=inactive, archived_repos=archived, stats=get_stats(), COLUMN_LABELS=COLUMN_LABELS, COLUMN_COLORS=COLUMN_COLORS, active_window_days=get_settings().active_window_days, ) @app.get("/board/{owner}/{repo}", response_class=HTMLResponse) async def board(request: Request, owner: str, repo: str): """Kanban board for a specific repo.""" full_name = f"{owner}/{repo}" all_items = await gitea.get_repo_issues(owner, repo, state="all") issues = [i for i in all_items if _is_issue(i)] open_issues = [i for i in issues if i.get("state") == "open"] closed_numbers = {i["number"] for i in issues if i.get("state") != "open"} open_numbers = {i["number"] for i in open_issues} # Keep local state truthful: closed in Gitea -> done, deleted -> purged. changes = reconcile_board_state(full_name, open_numbers, closed_numbers) for number in changes["closed_numbers"]: log_event(full_name, number, "closed", to_column="done", source="gitea") board_state = get_board_state(full_name) columns = _merge_issues_with_board(open_issues, board_state) labels: dict[str, dict] = {} for issue in open_issues: for label in issue.get("labels") or []: labels[label["name"]] = label repo_info = {} try: repo_info = await gitea.get_repo(owner, repo) except Exception as exc: # non-critical decoration logger.debug("repo info failed for %s: %s", full_name, exc) settings = get_settings() return render( request, "board.html", owner=owner, repo=repo, full_name=full_name, columns=columns, column_labels=COLUMN_LABELS, column_colors=COLUMN_COLORS, labels=sorted(labels.values(), key=lambda l: l["name"].lower()), notes=get_notes(full_name) or "", repo_info=repo_info, ages=_card_ages(full_name, open_issues, board_state), wip_limits=settings.wip_limits or {}, aging_days=settings.aging_days, ) def _card_ages( full_name: str, open_issues: list[dict], board: dict[str, list[dict]] ) -> dict[int, dict]: """Days each open card has spent in its current column. Source of truth, in order: the event journal (latest entry), board_state.updated_at (pre-journal data), the issue's own creation date. """ entered = column_entry_times(full_name) updated = { card["issue_number"]: card["updated_at"] for cards in board.values() for card in cards } now = datetime.now(timezone.utc) ages: dict[int, dict] = {} for issue in open_issues: number = issue["number"] raw = entered.get(number) or updated.get(number) or issue.get("created_at") since = _parse_datetime(raw) if since is None: continue if since.tzinfo is None: since = since.replace(tzinfo=timezone.utc) ages[number] = {"days": max(0, (now - since).days), "since": raw} return ages @app.get("/focus", response_class=HTMLResponse) async def focus(request: Request): """Cross-project view: everything in progress or in review.""" repos = await gitea.search_repos() active, _inactive, _archived = classify_repos(repos) active = active[:100] async def _open_issues(repo: dict): owner, _, name = repo["full_name"].partition("/") items = await gitea.get_repo_issues(owner, name, state="open") return repo["full_name"], [i for i in items if _is_issue(i)] results = await asyncio.gather( *(_open_issues(r) for r in active), return_exceptions=True ) groups = [] for result in results: if isinstance(result, Exception): logger.warning("focus: repo fetch failed: %s", result) continue full_name, open_issues = result columns = _merge_issues_with_board(open_issues, get_board_state(full_name)) cards = [ {"issue": issue, "column": col} for col in FOCUS_COLUMNS for issue in columns[col] ] if cards: groups.append({"full_name": full_name, "cards": cards}) return render( request, "focus.html", groups=groups, column_labels=COLUMN_LABELS, column_colors=COLUMN_COLORS, ) @app.get("/activity", response_class=HTMLResponse) async def activity(request: Request): """Activity feed: the latest entries of the event journal.""" events = get_events(limit=100) return render( request, "activity.html", events=events, event_icons=EVENT_ICONS, column_labels=COLUMN_LABELS, ) # ── Routes: API ──────────────────────────────────────────── @app.post("/api/move") async def api_move( request: Request, repo_full_name: str = Form(...), issue_number: int = Form(...), to_column: str = Form(...), from_column: str = Form(""), to_order: str = Form(""), from_order: str = Form(""), csrf_token: str = Form(""), ): """Move an issue to a new column and persist the resulting order. With KANBANTE_SYNC_GITEA enabled (default), moving to "done" closes the issue in Gitea and moving out of "done" reopens it. A failed write-back is reported in the response (gitea_sync="failed") instead of failing the move itself. """ if not _csrf_valid(request, csrf_token): return _csrf_error() if to_column not in KANBAN_COLUMNS: return JSONResponse( {"ok": False, "error": "Invalid column"}, status_code=400 ) parts = _split_full_name(repo_full_name) if parts is None: return JSONResponse( {"ok": False, "error": "Invalid repo_full_name"}, status_code=400 ) owner, repo = parts move_issue(repo_full_name, issue_number, to_column) if from_column in KANBAN_COLUMNS and from_column != to_column: remaining = [ n for n in _parse_order(from_order) if n != issue_number ] if remaining: set_column_order(repo_full_name, from_column, remaining) order = _parse_order(to_order) if order: if issue_number not in order: order.append(issue_number) set_column_order(repo_full_name, to_column, order) if from_column != to_column: log_event( repo_full_name, issue_number, "moved", from_column or None, to_column, ) result: dict = {"ok": True, "gitea_sync": "skipped"} settings = get_settings() if settings.sync_gitea: try: if to_column == "done": await gitea.set_issue_state(owner, repo, issue_number, "closed") result["gitea_sync"] = "closed" log_event(repo_full_name, issue_number, "closed", to_column="done") elif from_column == "done": await gitea.set_issue_state(owner, repo, issue_number, "open") result["gitea_sync"] = "reopened" log_event( repo_full_name, issue_number, "reopened", from_column="done", to_column=to_column, ) except httpx.HTTPError as exc: logger.warning( "Gitea write-back failed for %s#%s: %s", repo_full_name, issue_number, exc, ) result["gitea_sync"] = "failed" return result @app.post("/api/issues") async def api_create_issue( request: Request, repo_full_name: str = Form(...), title: str = Form(...), body: str = Form(""), column: str = Form("backlog"), csrf_token: str = Form(""), ): """Create a Gitea issue from the board and place its card.""" if not _csrf_valid(request, csrf_token): return _csrf_error() parts = _split_full_name(repo_full_name) if parts is None: return JSONResponse( {"ok": False, "error": "Invalid repo_full_name"}, status_code=400 ) title = title.strip() if not title or len(title) > 255: return JSONResponse( {"ok": False, "error": "Title required (max 255 chars)"}, status_code=400, ) if column not in KANBAN_COLUMNS: return JSONResponse( {"ok": False, "error": "Invalid column"}, status_code=400 ) owner, repo = parts issue = await gitea.create_issue(owner, repo, title, body) move_issue(repo_full_name, issue["number"], column) log_event(repo_full_name, issue["number"], "created", to_column=column) return JSONResponse( { "ok": True, "issue": { "number": issue["number"], "title": issue.get("title", title), "html_url": issue.get("html_url", ""), }, }, status_code=201, ) def _issue_payload(issue: dict) -> dict: assignees = issue.get("assignees") or [] if not assignees and issue.get("assignee"): assignees = [issue["assignee"]] milestone = issue.get("milestone") or {} return { "number": issue.get("number"), "title": issue.get("title", ""), "body": issue.get("body") or "", "state": issue.get("state", "open"), "html_url": issue.get("html_url", ""), "created_at": issue.get("created_at", ""), "updated_at": issue.get("updated_at", ""), "due_date": issue.get("due_date"), "labels": [ {"name": l.get("name", ""), "color": l.get("color", "")} for l in issue.get("labels") or [] ], "assignees": [ {"login": a.get("login", ""), "avatar_url": a.get("avatar_url", "")} for a in assignees ], "milestone": milestone.get("title"), } def _comment_payload(comment: dict) -> dict: user = comment.get("user") or {} return { "id": comment.get("id"), "body": comment.get("body") or "", "created_at": comment.get("created_at", ""), "updated_at": comment.get("updated_at", ""), "html_url": comment.get("html_url", ""), "user": { "login": user.get("login", "?"), "avatar_url": user.get("avatar_url", ""), }, } @app.get("/api/issues/{owner}/{repo}/{number}") async def api_issue_detail(owner: str, repo: str, number: int): """Issue detail + comments for the card panel (proxied from Gitea).""" try: issue = await gitea.get_issue(owner, repo, number) comments = await gitea.get_issue_comments(owner, repo, number) except httpx.HTTPStatusError as exc: if exc.response.status_code == 404: return JSONResponse( {"ok": False, "error": "Issue not found"}, status_code=404 ) raise return { "ok": True, "issue": _issue_payload(issue), "comments": [_comment_payload(c) for c in comments], } @app.post("/api/issues/{owner}/{repo}/{number}/comments") async def api_issue_comment( request: Request, owner: str, repo: str, number: int, body: str = Form(...), csrf_token: str = Form(""), ): """Post a comment on the issue in Gitea.""" if not _csrf_valid(request, csrf_token): return _csrf_error() body = body.strip() if not body: return JSONResponse( {"ok": False, "error": "Comment must not be empty"}, status_code=400 ) if len(body) > 10_000: return JSONResponse( {"ok": False, "error": "Comment too large (max 10000 chars)"}, status_code=413, ) comment = await gitea.create_comment(owner, repo, number, body) log_event(f"{owner}/{repo}", number, "commented") return JSONResponse( {"ok": True, "comment": _comment_payload(comment)}, status_code=201 ) @app.get("/api/events") async def api_events(repo: str = "", limit: int = 100): """Latest journal entries (all repos, or one via ?repo=owner/name).""" events = get_events(repo or None, max(1, min(limit, 500))) return {"ok": True, "events": events} @app.get("/api/metrics") async def api_metrics(repo: str = "", window: int = 30): """Throughput / cycle-time metrics from the journal.""" return {"ok": True, "metrics": get_metrics(repo or None, window)} @app.post("/api/notes") async def api_notes( request: Request, repo_full_name: str = Form(...), note: str = Form(""), csrf_token: str = Form(""), ): """Save project notes.""" if not _csrf_valid(request, csrf_token): return _csrf_error() settings = get_settings() if len(note) > settings.max_note_chars: return JSONResponse( { "ok": False, "error": f"Note too large (max {settings.max_note_chars} chars)", }, status_code=413, ) set_notes(repo_full_name, note) return {"ok": True} @app.get("/api/stats") async def api_stats(): """Get project stats.""" return get_stats() @app.get("/api/projects") async def api_projects(): """Get all projects (JSON), classified like the dashboard.""" repos = await gitea.search_repos() active, inactive, archived = classify_repos(repos) return {"active": active, "inactive": inactive, "archived": archived} @app.get("/api/health") async def health(): """Liveness probe: the process is up. Deliberately dependency-free.""" return {"status": "ok", "version": APP_VERSION} @app.get("/api/readiness") async def readiness(): """Readiness probe: database writable and Gitea reachable.""" db_ok = ping_db() gitea_ok = await gitea.ping() ready = db_ok and gitea_ok return JSONResponse( { "status": "ready" if ready else "not_ready", "version": APP_VERSION, "checks": {"database": db_ok, "gitea": gitea_ok}, }, status_code=200 if ready else 503, )