From d267df01fa86cb22921ca8b9c4091a255db9e2d6 Mon Sep 17 00:00:00 2001 From: Spark Date: Sun, 11 Oct 2026 09:31:48 -0400 Subject: [PATCH] =?UTF-8?q?fix(backend):=20consolidation=200.2.0=20?= =?UTF-8?q?=E2=80=94=20config,=20DB=5FPATH,=20async,=20pagination,=20auth,?= =?UTF-8?q?=20synchro=20bidirectionnelle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.py: réglages centralisés par variables d'environnement - models: KANBANTE_DB_PATH enfin honorée (C1), migrations idempotentes (position, notes.updated_at, nettoyage colonnes inconnues — I4), ordre des cartes persisté (M5), réconciliation avec Gitea - gitea_client: httpx asynchrone, pagination complète (I2, plafond serveur de 50), uid résolu via /user, en-tête Authorization seulement si jeton, cache court, create_issue / set_issue_state, .env racine - main: lifespan au lieu de on_event (M4), Basic Auth optionnelle + CSRF double-submit (C2), write-back done<->fermé (C3), classification par fenêtre glissante + drapeau archived (I1), erreurs Gitea en 502 propre (I5), /api/readiness réel, PR exclues du board (M6), codes HTTP explicites et limite de taille des notes (M7) --- app/config.py | 87 ++++++++ app/gitea_client.py | 227 +++++++++++++++---- app/main.py | 522 +++++++++++++++++++++++++++++++++++++------- app/models.py | 276 +++++++++++++++++------ 4 files changed, 921 insertions(+), 191 deletions(-) create mode 100644 app/config.py diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..7a22afa --- /dev/null +++ b/app/config.py @@ -0,0 +1,87 @@ +""" +Centralised settings for Kanbante. + +Every setting is read from the environment at call time (no import-time +freezing), so tests and operators can change them without code edits. + +Environment variables: + GITEA_URL Base URL of the Gitea instance. + GITEA_TOKEN Gitea API token (read/write issues). + KANBANTE_DB_PATH Path of the local SQLite database. + KANBANTE_AUTH_USER Basic-auth username (with _PASSWORD: enables auth). + KANBANTE_AUTH_PASSWORD Basic-auth password. + KANBANTE_REQUIRE_AUTH "1" = refuse to serve if auth is not configured. + KANBANTE_ACTIVE_WINDOW_DAYS A non-archived repo updated within this many + days counts as "active" (default 365). + KANBANTE_CACHE_TTL Seconds to cache Gitea list responses (default 30). + KANBANTE_MAX_NOTE_CHARS Maximum size of a project note (default 20000). + KANBANTE_SYNC_GITEA "1" (default) = moving a card to/from "done" + closes / reopens the Gitea issue. +""" + +import os +from dataclasses import dataclass +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +APP_VERSION = "0.2.0" + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + try: + return int(raw) if raw is not None else default + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + raw = os.getenv(name) + try: + return float(raw) if raw is not None else default + except ValueError: + return default + + +@dataclass(frozen=True) +class Settings: + gitea_url: str = "https://git.dracodev.net" + gitea_token: str = "" + db_path: str = "~/.kanbante/kanbante.db" + auth_user: str = "" + auth_password: str = "" + require_auth: bool = False + active_window_days: int = 365 + cache_ttl: float = 30.0 + max_note_chars: int = 20_000 + sync_gitea: bool = True + + @property + def auth_enabled(self) -> bool: + return bool(self.auth_user and self.auth_password) + + +def get_settings() -> Settings: + """Build settings from the current environment.""" + return Settings( + gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"), + gitea_token=os.getenv("GITEA_TOKEN", ""), + db_path=os.getenv( + "KANBANTE_DB_PATH", os.path.expanduser("~/.kanbante/kanbante.db") + ), + auth_user=os.getenv("KANBANTE_AUTH_USER", ""), + auth_password=os.getenv("KANBANTE_AUTH_PASSWORD", ""), + require_auth=_env_bool("KANBANTE_REQUIRE_AUTH", False), + active_window_days=_env_int("KANBANTE_ACTIVE_WINDOW_DAYS", 365), + cache_ttl=_env_float("KANBANTE_CACHE_TTL", 30.0), + max_note_chars=_env_int("KANBANTE_MAX_NOTE_CHARS", 20_000), + sync_gitea=_env_bool("KANBANTE_SYNC_GITEA", True), + ) diff --git a/app/gitea_client.py b/app/gitea_client.py index f040391..9194a55 100644 --- a/app/gitea_client.py +++ b/app/gitea_client.py @@ -1,62 +1,187 @@ """ -Gitea API client for Kanbante. +Async Gitea API client for Kanbante. + +Changes from the 0.1.0 client: +- Fully asynchronous (httpx.AsyncClient): routes no longer block the + event loop on Gitea calls. +- Real pagination: Gitea caps every list response at MAX_RESPONSE_ITEMS + (50 by default) and silently truncates larger `limit` values, so all + list methods follow pages until the result is complete. +- The Authorization header is only sent when a token is configured. +- The current user's id is resolved from /user (no hardcoded uid or + username); without a token it falls back to a public search. +- Write operations used by the two-way sync: create_issue() and + set_issue_state(). +- Short in-memory cache for list responses (KANBANTE_CACHE_TTL seconds), + invalidated on every mutation. +- .env loading targets the project root only (no hardcoded home path). """ -import os +import logging +import time +from pathlib import Path from typing import Any import httpx from dotenv import load_dotenv -load_dotenv(os.path.expanduser("~/workspace/.env")) +from app.config import get_settings + +load_dotenv(Path(__file__).resolve().parent.parent / ".env") + +logger = logging.getLogger("kanbante.gitea") + +PAGE_SIZE = 50 # Gitea's default MAX_RESPONSE_ITEMS +MAX_PAGES = 40 # hard stop: 40 * 50 = 2000 items per list class GiteaClient: - """Lightweight Gitea API client for Kanbante.""" + """Lightweight async Gitea API client for Kanbante.""" - def __init__(self) -> None: - self.base_url = os.getenv("GITEA_URL", "https://git.dracodev.net") - self.token = os.getenv("GITEA_TOKEN", "") - self.client = httpx.Client( + def __init__( + self, + base_url: str | None = None, + token: str | None = None, + cache_ttl: float | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + settings = get_settings() + self.base_url = (base_url or settings.gitea_url).rstrip("/") + self.token = settings.gitea_token if token is None else token + self.cache_ttl = settings.cache_ttl if cache_ttl is None else cache_ttl + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = f"token {self.token}" + self.client = httpx.AsyncClient( base_url=f"{self.base_url}/api/v1", - headers={ - "Authorization": f"token {self.token}", - "Accept": "application/json", - }, + headers=headers, timeout=15.0, + transport=transport, ) + self._user: dict | None = None + self._cache: dict[str, tuple[float, Any]] = {} - def _get(self, path: str, **params: Any) -> dict | list: - r = self.client.get(path, params=params) + async def aclose(self) -> None: + await self.client.aclose() + + # ── Low-level helpers ────────────────────────────────── + + async def _get(self, path: str, **params: Any) -> Any: + r = await self.client.get(path, params=params) r.raise_for_status() return r.json() - def get_user_repos(self, username: str = "bruno") -> list[dict]: - """Get all repos for a user.""" - return self._get(f"/users/{username}/repos", limit=100) + async def _get_all_pages(self, path: str, **params: Any) -> list[dict]: + """Fetch every page of a list endpoint. - def search_repos(self, uid: int = 1) -> list[dict]: - """Search all repos visible to user.""" - result = self._get("/repos/search", uid=uid, limit=200) - if isinstance(result, dict) and "data" in result: - return result["data"] - return result if isinstance(result, list) else [] + Stops on a short page, on X-Total-Count when Gitea provides it, + or at MAX_PAGES as a safety net. + """ + items: list[dict] = [] + page = 1 + while page <= MAX_PAGES: + r = await self.client.get( + path, params={**params, "page": page, "limit": PAGE_SIZE} + ) + r.raise_for_status() + data = r.json() + if isinstance(data, dict) and "data" in data: + batch = data["data"] + elif isinstance(data, list): + batch = data + else: + batch = [] + items.extend(batch) - def get_org_repos(self, org: str) -> list[dict]: - """Get repos for an organization.""" - return self._get(f"/orgs/{org}/repos", limit=50) + total = r.headers.get("X-Total-Count") + if total is not None: + try: + if len(items) >= int(total): + break + except ValueError: + pass + if len(batch) < PAGE_SIZE: + break + page += 1 + return items - def get_repo_issues( + def _cache_get(self, key: str) -> Any | None: + if self.cache_ttl <= 0: + return None + hit = self._cache.get(key) + if hit and (time.monotonic() - hit[0]) < self.cache_ttl: + return hit[1] + return None + + def _cache_put(self, key: str, value: Any) -> None: + if self.cache_ttl > 0: + self._cache[key] = (time.monotonic(), value) + + def _cache_clear(self) -> None: + self._cache.clear() + + # ── API ──────────────────────────────────────────────── + + async def ping(self) -> bool: + """True if the Gitea instance answers /version.""" + try: + r = await self.client.get("/version", timeout=5.0) + return r.status_code == 200 + except Exception: + return False + + async def get_user(self) -> dict: + """Get authenticated user info (cached on the instance).""" + if self._user is None: + self._user = await self._get("/user") + return self._user + + async def search_repos(self) -> list[dict]: + """All repos visible to the token owner (paginated, cached).""" + cached = self._cache_get("search_repos") + if cached is not None: + return cached + params: dict[str, Any] = {} + try: + user = await self.get_user() + if user and user.get("id"): + params["uid"] = user["id"] + except Exception as exc: # no/invalid token → public search + logger.debug("get_user failed, falling back to public search: %s", exc) + repos = await self._get_all_pages("/repos/search", **params) + self._cache_put("search_repos", repos) + return repos + + async def get_repo(self, owner: str, repo: str) -> dict: + return await self._get(f"/repos/{owner}/{repo}") + + async def get_user_repos(self, username: str) -> list[dict]: + """Get all repos for a user (paginated).""" + return await self._get_all_pages(f"/users/{username}/repos") + + async def get_org_repos(self, org: str) -> list[dict]: + """Get all repos for an organization (paginated).""" + return await self._get_all_pages(f"/orgs/{org}/repos") + + async def get_repo_issues( self, owner: str, repo: str, state: str = "all" ) -> list[dict]: - """Get issues for a repo.""" - return self._get( - f"/repos/{owner}/{repo}/issues", - state=state, - limit=100, - ) + """Get issues for a repo (paginated, cached). - def get_all_issues( + Note: Gitea's issues endpoint also returns pull requests; each + PR carries a "pull_request" key and callers filter on it. + """ + key = f"issues:{owner}/{repo}:{state}" + cached = self._cache_get(key) + if cached is not None: + return cached + issues = await self._get_all_pages( + f"/repos/{owner}/{repo}/issues", state=state + ) + self._cache_put(key, issues) + return issues + + async def get_all_issues( self, repos: list[dict], state: str = "open" ) -> dict[str, list[dict]]: """Get issues for multiple repos, keyed by full_name.""" @@ -65,12 +190,34 @@ class GiteaClient: full_name = repo["full_name"] owner, name = full_name.split("/", 1) try: - issues = self.get_repo_issues(owner, name, state=state) - issues_by_repo[full_name] = issues - except Exception: + issues_by_repo[full_name] = await self.get_repo_issues( + owner, name, state=state + ) + except Exception as exc: + logger.warning("issues fetch failed for %s: %s", full_name, exc) issues_by_repo[full_name] = [] return issues_by_repo - def get_user(self) -> dict: - """Get authenticated user info.""" - return self._get("/user") + async def create_issue( + self, owner: str, repo: str, title: str, body: str = "" + ) -> dict: + """Create an issue in Gitea and return it.""" + r = await self.client.post( + f"/repos/{owner}/{repo}/issues", json={"title": title, "body": body} + ) + r.raise_for_status() + self._cache_clear() + return r.json() + + async def set_issue_state( + self, owner: str, repo: str, issue_number: int, state: str + ) -> dict: + """Open or close an issue in Gitea (state: "open" | "closed").""" + if state not in ("open", "closed"): + raise ValueError(f"Invalid issue state: {state}") + r = await self.client.patch( + f"/repos/{owner}/{repo}/issues/{issue_number}", json={"state": state} + ) + r.raise_for_status() + self._cache_clear() + return r.json() diff --git a/app/main.py b/app/main.py index bbf8dac..0b12701 100644 --- a/app/main.py +++ b/app/main.py @@ -1,50 +1,55 @@ """ 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 os +import asyncio +import base64 +import logging +import secrets +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone from pathlib import Path -from fastapi import FastAPI, Request, Form -from fastapi.responses import HTMLResponse +import httpx +from fastapi import FastAPI, Form, Request +from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates +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 ( - init_db, - get_board_state, - move_issue, - set_notes, - get_notes, KANBAN_COLUMNS, + get_board_state, + get_notes, get_stats, + init_db, + move_issue, + ping_db, + reconcile_board_state, + set_column_order, + set_notes, ) -from fastapi.templating import Jinja2Templates as _Jinja2Templates -from jinja2 import Environment, FileSystemLoader, select_autoescape +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("kanbante") -# ── App setup ────────────────────────────────────────────── BASE = Path(__file__).resolve().parent.parent -app = FastAPI(title="Kanbante", version="0.1.0") -app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static") -gitea = GiteaClient() +CSRF_COOKIE = "kanbante_csrf" -# ── Templates (workaround Starlette 1.x + Jinja2 cache bug) ── -_tpl_env = Environment( - loader=FileSystemLoader(str(BASE / "app" / "templates")), - autoescape=select_autoescape(["html"]), -) - - -def render_template(name: str, **context) -> HTMLResponse: - """Render a Jinja2 template directly (bypasses Starlette's buggy caching).""" - template = _tpl_env.get_template(name) - html = template.render(**context) - return HTMLResponse(html) - -# Column display names COLUMN_LABELS = { "backlog": "📥 Backlog", "todo": "📋 À faire", @@ -61,112 +66,450 @@ COLUMN_COLORS = { "done": "#10b981", } +FOCUS_COLUMNS = ["in_progress", "review"] -@app.on_event("startup") -async def startup(): + +# ── 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 _is_active(repo: dict) -> bool: - """A repo is active if updated in 2026.""" - return repo.get("updated_at", "")[:4] == "2026" +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 _merge_issues_with_board(issues: list[dict], board: dict[str, list[dict]]) -> dict[str, list[dict]]: - """Merge Gitea issues into kanban columns based on board state.""" - columns = {col: [] for col in KANBAN_COLUMNS} +def classify_repos( + repos: list[dict], now: datetime | None = None +) -> tuple[list[dict], list[dict], list[dict]]: + """Split repos into (active, inactive, archived). - # Build a lookup of board state by issue number - issue_positions: dict[int, str] = {} + "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: - issue_positions[card["issue_number"]] = col_name + placement[card["issue_number"]] = (col_name, card["position"]) - for issue in issues: - num = issue["number"] - col = issue_positions.get(num, "backlog") + 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 = gitea.search_repos() - active = [r for r in repos if _is_active(r)] - inactive = [r for r in repos if not _is_active(r)] - - stats = get_stats() - - ctx = { - "active_repos": list(active), - "inactive_repos": list(inactive), - "stats": {k: dict(v) for k, v in stats.items()}, - "COLUMN_LABELS": dict(COLUMN_LABELS), - "COLUMN_COLORS": dict(COLUMN_COLORS), - } - return render_template("dashboard.html", **ctx) + 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}" - issues = gitea.get_repo_issues(owner, repo, state="open") - board_state = get_board_state(full_name) - columns = _merge_issues_with_board(issues, board_state) + 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} - notes = get_notes(full_name) or "" + # Keep local state truthful: closed in Gitea -> done, deleted -> purged. + reconcile_board_state(full_name, open_numbers, closed_numbers) + + columns = _merge_issues_with_board(open_issues, get_board_state(full_name)) + + labels: dict[str, dict] = {} + for issue in open_issues: + for label in issue.get("labels") or []: + labels[label["name"]] = label - # Get repo info repo_info = {} try: - import httpx - client = gitea.client - r = client.get(f"/repos/{owner}/{repo}") - if r.status_code == 200: - repo_info = r.json() - except Exception: - pass + 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) - return render_template("board.html", + return render( + request, + "board.html", owner=owner, repo=repo, full_name=full_name, columns=columns, column_labels=COLUMN_LABELS, column_colors=COLUMN_COLORS, - notes=notes, + labels=sorted(labels.values(), key=lambda l: l["name"].lower()), + notes=get_notes(full_name) or "", repo_info=repo_info, ) +@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, + ) + + # ── 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.""" + """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 {"ok": False, "error": "Invalid column"} + 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) - return {"ok": True} + 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) + + 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" + elif from_column == "done": + await gitea.set_issue_state(owner, repo, issue_number, "open") + result["gitea_sync"] = "reopened" + 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) + return JSONResponse( + { + "ok": True, + "issue": { + "number": issue["number"], + "title": issue.get("title", title), + "html_url": issue.get("html_url", ""), + }, + }, + status_code=201, + ) @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} @@ -179,14 +522,29 @@ async def api_stats(): @app.get("/api/projects") async def api_projects(): - """Get all projects (JSON).""" - repos = gitea.search_repos() - return { - "active": [r for r in repos if _is_active(r)], - "inactive": [r for r in repos if not _is_active(r)], - } + """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(): - return {"status": "ok", "version": "0.1.0"} + """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, + ) diff --git a/app/models.py b/app/models.py index 7f87686..aa3a51d 100644 --- a/app/models.py +++ b/app/models.py @@ -1,64 +1,132 @@ """ SQLite models for Kanbante — lightweight local state. + +Local state only stores what Gitea does not: the column of each issue, +its position inside the column, and per-project notes. The database path +honours the KANBANTE_DB_PATH environment variable (see app.config) — +that is the path the Dockerfile / docker-compose mount their volume on. + +Migrations are additive and idempotent: init_db() creates fresh schemas +and upgrades older databases in place (position column, notes +updated_at, cleanup of rows carrying an unknown column name). """ -import sqlite3 import os -from datetime import datetime +import sqlite3 +from contextlib import contextmanager +from typing import Iterator -DB_PATH = os.path.expanduser("~/.kanbante/kanbante.db") KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "review", "done"] -def get_db() -> sqlite3.Connection: - os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) - conn = sqlite3.connect(DB_PATH) +def db_path() -> str: + """Resolve the database path from the environment (per call).""" + return os.path.expanduser( + os.getenv("KANBANTE_DB_PATH", "~/.kanbante/kanbante.db") + ) + + +@contextmanager +def get_db() -> Iterator[sqlite3.Connection]: + path = db_path() + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + conn = sqlite3.connect(path) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") - return conn + try: + yield conn + conn.commit() + finally: + conn.close() def init_db() -> None: - conn = get_db() - conn.executescript(""" - CREATE TABLE IF NOT EXISTS board_state ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - repo_full_name TEXT NOT NULL, - issue_number INTEGER NOT NULL, - column_name TEXT NOT NULL DEFAULT 'backlog', - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(repo_full_name, issue_number) - ); + with get_db() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS board_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_full_name TEXT NOT NULL, + issue_number INTEGER NOT NULL, + column_name TEXT NOT NULL DEFAULT 'backlog', + position REAL NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(repo_full_name, issue_number) + ); - CREATE TABLE IF NOT EXISTS project_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - repo_full_name TEXT NOT NULL, - note TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(repo_full_name) - ); + CREATE TABLE IF NOT EXISTS project_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_full_name TEXT NOT NULL, + note TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(repo_full_name) + ); - CREATE INDEX IF NOT EXISTS idx_board_repo - ON board_state(repo_full_name, column_name); - """) - conn.commit() - conn.close() + CREATE INDEX IF NOT EXISTS idx_board_repo + ON board_state(repo_full_name, column_name); + """ + ) + _migrate(conn) + + +def _migrate(conn: sqlite3.Connection) -> None: + """Upgrade schemas created by Kanbante <= 0.1.0.""" + board_cols = [r[1] for r in conn.execute("PRAGMA table_info(board_state)")] + if board_cols and "position" not in board_cols: + conn.execute( + "ALTER TABLE board_state ADD COLUMN position REAL NOT NULL DEFAULT 0" + ) + notes_cols = [r[1] for r in conn.execute("PRAGMA table_info(project_notes)")] + if notes_cols and "updated_at" not in notes_cols: + # SQLite rejects non-constant defaults in ADD COLUMN: add the + # column plainly, then backfill from created_at. + conn.execute("ALTER TABLE project_notes ADD COLUMN updated_at TEXT") + conn.execute( + "UPDATE project_notes SET updated_at = created_at " + "WHERE updated_at IS NULL" + ) + # Rows written under a column name that no longer exists used to crash + # get_board_state() with a KeyError. Drop them once, here. + placeholders = ",".join("?" for _ in KANBAN_COLUMNS) + conn.execute( + f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders})", + KANBAN_COLUMNS, + ) + + +def ping_db() -> bool: + """Cheap check used by /api/readiness.""" + try: + with get_db() as conn: + conn.execute("SELECT 1").fetchone() + return True + except Exception: + return False def get_board_state(repo_full_name: str) -> dict[str, list[dict]]: - """Return board columns with their issues.""" - conn = get_db() - rows = conn.execute( - "SELECT issue_number, column_name, updated_at FROM board_state WHERE repo_full_name = ?", - (repo_full_name,), - ).fetchall() - conn.close() + """Return board columns with their issues, ordered by position.""" + with get_db() as conn: + rows = conn.execute( + "SELECT issue_number, column_name, position, updated_at " + "FROM board_state WHERE repo_full_name = ? " + "ORDER BY position ASC, issue_number ASC", + (repo_full_name,), + ).fetchall() columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS} for row in rows: - columns[row["column_name"]].append( + column = row["column_name"] + if column not in columns: + # Defensive: never let one bad row break the whole board. + continue + columns[column].append( { "issue_number": row["issue_number"], + "position": row["position"], "updated_at": row["updated_at"], } ) @@ -66,52 +134,122 @@ def get_board_state(repo_full_name: str) -> dict[str, list[dict]]: def move_issue(repo_full_name: str, issue_number: int, to_column: str) -> None: - """Move an issue to a different column.""" - conn = get_db() - conn.execute( - """INSERT INTO board_state (repo_full_name, issue_number, column_name, updated_at) - VALUES (?, ?, ?, datetime('now')) - ON CONFLICT(repo_full_name, issue_number) - DO UPDATE SET column_name = excluded.column_name, updated_at = excluded.updated_at""", - (repo_full_name, issue_number, to_column), - ) - conn.commit() - conn.close() + """Move an issue to a different column (appended at the end).""" + if to_column not in KANBAN_COLUMNS: + raise ValueError(f"Invalid column: {to_column}") + with get_db() as conn: + row = conn.execute( + "SELECT COALESCE(MAX(position), -1) + 1 AS next_pos FROM board_state " + "WHERE repo_full_name = ? AND column_name = ?", + (repo_full_name, to_column), + ).fetchone() + conn.execute( + """INSERT INTO board_state + (repo_full_name, issue_number, column_name, position, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(repo_full_name, issue_number) + DO UPDATE SET column_name = excluded.column_name, + position = excluded.position, + updated_at = excluded.updated_at""", + (repo_full_name, issue_number, to_column, row["next_pos"]), + ) + + +def set_column_order( + repo_full_name: str, column_name: str, ordered_issue_numbers: list[int] +) -> None: + """Persist the exact order of the cards in one column. + + Each listed card keeps (or gains) this column and its position becomes + its index in the list. Used after a drag & drop so manual ordering + survives reloads. + """ + if column_name not in KANBAN_COLUMNS: + raise ValueError(f"Invalid column: {column_name}") + with get_db() as conn: + for index, issue_number in enumerate(ordered_issue_numbers): + conn.execute( + """INSERT INTO board_state + (repo_full_name, issue_number, column_name, position, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(repo_full_name, issue_number) + DO UPDATE SET column_name = excluded.column_name, + position = excluded.position, + updated_at = excluded.updated_at""", + (repo_full_name, issue_number, column_name, float(index)), + ) + + +def reconcile_board_state( + repo_full_name: str, open_numbers: set[int], closed_numbers: set[int] +) -> dict[str, int]: + """Reconcile local board state with the truth in Gitea. + + - Issues closed in Gitea move to the "done" column. + - Rows for issues that no longer exist in Gitea are deleted. + Returns counters of what changed. + """ + changed = {"closed_to_done": 0, "purged": 0} + known = open_numbers | closed_numbers + with get_db() as conn: + rows = conn.execute( + "SELECT issue_number, column_name FROM board_state " + "WHERE repo_full_name = ?", + (repo_full_name,), + ).fetchall() + for row in rows: + number = row["issue_number"] + if number not in known: + conn.execute( + "DELETE FROM board_state " + "WHERE repo_full_name = ? AND issue_number = ?", + (repo_full_name, number), + ) + changed["purged"] += 1 + elif number in closed_numbers and row["column_name"] != "done": + conn.execute( + "UPDATE board_state SET column_name = 'done', " + "updated_at = datetime('now') " + "WHERE repo_full_name = ? AND issue_number = ?", + (repo_full_name, number), + ) + changed["closed_to_done"] += 1 + return changed def set_notes(repo_full_name: str, note: str) -> None: - conn = get_db() - conn.execute( - """INSERT INTO project_notes (repo_full_name, note) - VALUES (?, ?) - ON CONFLICT(repo_full_name) - DO UPDATE SET note = excluded.note""", - (repo_full_name, note), - ) - conn.commit() - conn.close() + with get_db() as conn: + conn.execute( + """INSERT INTO project_notes (repo_full_name, note) + VALUES (?, ?) + ON CONFLICT(repo_full_name) + DO UPDATE SET note = excluded.note, + updated_at = datetime('now')""", + (repo_full_name, note), + ) def get_notes(repo_full_name: str) -> str | None: - conn = get_db() - row = conn.execute( - "SELECT note FROM project_notes WHERE repo_full_name = ?", - (repo_full_name,), - ).fetchone() - conn.close() + with get_db() as conn: + row = conn.execute( + "SELECT note FROM project_notes WHERE repo_full_name = ?", + (repo_full_name,), + ).fetchone() return row["note"] if row else None def get_stats() -> dict: """Get overall stats across all projects.""" - conn = get_db() - rows = conn.execute( - "SELECT repo_full_name, column_name, COUNT(*) as cnt FROM board_state GROUP BY repo_full_name, column_name" - ).fetchall() - conn.close() + with get_db() as conn: + rows = conn.execute( + "SELECT repo_full_name, column_name, COUNT(*) as cnt " + "FROM board_state GROUP BY repo_full_name, column_name" + ).fetchall() projects: dict[str, dict[str, int]] = {} for row in rows: + if row["column_name"] not in KANBAN_COLUMNS: + continue name = row["repo_full_name"] if name not in projects: projects[name] = {col: 0 for col in KANBAN_COLUMNS}