fix(backend): consolidation 0.2.0 — config, DB_PATH, async, pagination, auth, synchro bidirectionnelle
- 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)
This commit is contained in:
@@ -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),
|
||||||
|
)
|
||||||
+187
-40
@@ -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
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from dotenv import load_dotenv
|
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:
|
class GiteaClient:
|
||||||
"""Lightweight Gitea API client for Kanbante."""
|
"""Lightweight async Gitea API client for Kanbante."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(
|
||||||
self.base_url = os.getenv("GITEA_URL", "https://git.dracodev.net")
|
self,
|
||||||
self.token = os.getenv("GITEA_TOKEN", "")
|
base_url: str | None = None,
|
||||||
self.client = httpx.Client(
|
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",
|
base_url=f"{self.base_url}/api/v1",
|
||||||
headers={
|
headers=headers,
|
||||||
"Authorization": f"token {self.token}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
},
|
|
||||||
timeout=15.0,
|
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:
|
async def aclose(self) -> None:
|
||||||
r = self.client.get(path, params=params)
|
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()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def get_user_repos(self, username: str = "bruno") -> list[dict]:
|
async def _get_all_pages(self, path: str, **params: Any) -> list[dict]:
|
||||||
"""Get all repos for a user."""
|
"""Fetch every page of a list endpoint.
|
||||||
return self._get(f"/users/{username}/repos", limit=100)
|
|
||||||
|
|
||||||
def search_repos(self, uid: int = 1) -> list[dict]:
|
Stops on a short page, on X-Total-Count when Gitea provides it,
|
||||||
"""Search all repos visible to user."""
|
or at MAX_PAGES as a safety net.
|
||||||
result = self._get("/repos/search", uid=uid, limit=200)
|
"""
|
||||||
if isinstance(result, dict) and "data" in result:
|
items: list[dict] = []
|
||||||
return result["data"]
|
page = 1
|
||||||
return result if isinstance(result, list) else []
|
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]:
|
total = r.headers.get("X-Total-Count")
|
||||||
"""Get repos for an organization."""
|
if total is not None:
|
||||||
return self._get(f"/orgs/{org}/repos", limit=50)
|
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"
|
self, owner: str, repo: str, state: str = "all"
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get issues for a repo."""
|
"""Get issues for a repo (paginated, cached).
|
||||||
return self._get(
|
|
||||||
f"/repos/{owner}/{repo}/issues",
|
|
||||||
state=state,
|
|
||||||
limit=100,
|
|
||||||
)
|
|
||||||
|
|
||||||
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"
|
self, repos: list[dict], state: str = "open"
|
||||||
) -> dict[str, list[dict]]:
|
) -> dict[str, list[dict]]:
|
||||||
"""Get issues for multiple repos, keyed by full_name."""
|
"""Get issues for multiple repos, keyed by full_name."""
|
||||||
@@ -65,12 +190,34 @@ class GiteaClient:
|
|||||||
full_name = repo["full_name"]
|
full_name = repo["full_name"]
|
||||||
owner, name = full_name.split("/", 1)
|
owner, name = full_name.split("/", 1)
|
||||||
try:
|
try:
|
||||||
issues = self.get_repo_issues(owner, name, state=state)
|
issues_by_repo[full_name] = await self.get_repo_issues(
|
||||||
issues_by_repo[full_name] = issues
|
owner, name, state=state
|
||||||
except Exception:
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("issues fetch failed for %s: %s", full_name, exc)
|
||||||
issues_by_repo[full_name] = []
|
issues_by_repo[full_name] = []
|
||||||
return issues_by_repo
|
return issues_by_repo
|
||||||
|
|
||||||
def get_user(self) -> dict:
|
async def create_issue(
|
||||||
"""Get authenticated user info."""
|
self, owner: str, repo: str, title: str, body: str = ""
|
||||||
return self._get("/user")
|
) -> 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()
|
||||||
|
|||||||
+440
-82
@@ -1,50 +1,55 @@
|
|||||||
"""
|
"""
|
||||||
Kanbante — Kanban project manager integrated with Gitea.
|
Kanbante — Kanban project manager integrated with Gitea.
|
||||||
FastAPI backend serving HTML + JSON API.
|
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 pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Form
|
import httpx
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi import FastAPI, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
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.gitea_client import GiteaClient
|
||||||
from app.models import (
|
from app.models import (
|
||||||
init_db,
|
|
||||||
get_board_state,
|
|
||||||
move_issue,
|
|
||||||
set_notes,
|
|
||||||
get_notes,
|
|
||||||
KANBAN_COLUMNS,
|
KANBAN_COLUMNS,
|
||||||
|
get_board_state,
|
||||||
|
get_notes,
|
||||||
get_stats,
|
get_stats,
|
||||||
|
init_db,
|
||||||
|
move_issue,
|
||||||
|
ping_db,
|
||||||
|
reconcile_board_state,
|
||||||
|
set_column_order,
|
||||||
|
set_notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
from fastapi.templating import Jinja2Templates as _Jinja2Templates
|
logging.basicConfig(level=logging.INFO)
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
logger = logging.getLogger("kanbante")
|
||||||
|
|
||||||
# ── App setup ──────────────────────────────────────────────
|
|
||||||
BASE = Path(__file__).resolve().parent.parent
|
BASE = Path(__file__).resolve().parent.parent
|
||||||
app = FastAPI(title="Kanbante", version="0.1.0")
|
CSRF_COOKIE = "kanbante_csrf"
|
||||||
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
|
|
||||||
gitea = GiteaClient()
|
|
||||||
|
|
||||||
# ── 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 = {
|
COLUMN_LABELS = {
|
||||||
"backlog": "📥 Backlog",
|
"backlog": "📥 Backlog",
|
||||||
"todo": "📋 À faire",
|
"todo": "📋 À faire",
|
||||||
@@ -61,112 +66,450 @@ COLUMN_COLORS = {
|
|||||||
"done": "#10b981",
|
"done": "#10b981",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FOCUS_COLUMNS = ["in_progress", "review"]
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup():
|
# ── App setup ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
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 ────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
def _is_active(repo: dict) -> bool:
|
def _parse_datetime(value: str | None) -> datetime | None:
|
||||||
"""A repo is active if updated in 2026."""
|
if not value:
|
||||||
return repo.get("updated_at", "")[:4] == "2026"
|
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]]:
|
def classify_repos(
|
||||||
"""Merge Gitea issues into kanban columns based on board state."""
|
repos: list[dict], now: datetime | None = None
|
||||||
columns = {col: [] for col in KANBAN_COLUMNS}
|
) -> tuple[list[dict], list[dict], list[dict]]:
|
||||||
|
"""Split repos into (active, inactive, archived).
|
||||||
|
|
||||||
# Build a lookup of board state by issue number
|
"Archived" is Gitea's own flag. Among the rest, a repo is active if
|
||||||
issue_positions: dict[int, str] = {}
|
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 col_name, cards in board.items():
|
||||||
for card in cards:
|
for card in cards:
|
||||||
issue_positions[card["issue_number"]] = col_name
|
placement[card["issue_number"]] = (col_name, card["position"])
|
||||||
|
|
||||||
for issue in issues:
|
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
|
||||||
num = issue["number"]
|
for index, issue in enumerate(issues):
|
||||||
col = issue_positions.get(num, "backlog")
|
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)
|
columns[col].append(issue)
|
||||||
|
for col in columns.values():
|
||||||
|
col.sort(key=lambda i: i["_sort"])
|
||||||
return columns
|
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 ─────────────────────────────────────────
|
# ── Routes: Pages ─────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def dashboard(request: Request):
|
async def dashboard(request: Request):
|
||||||
"""Main dashboard — all projects at a glance."""
|
"""Main dashboard — all projects at a glance."""
|
||||||
repos = gitea.search_repos()
|
repos = await gitea.search_repos()
|
||||||
active = [r for r in repos if _is_active(r)]
|
active, inactive, archived = classify_repos(repos)
|
||||||
inactive = [r for r in repos if not _is_active(r)]
|
return render(
|
||||||
|
request,
|
||||||
stats = get_stats()
|
"dashboard.html",
|
||||||
|
active_repos=active,
|
||||||
ctx = {
|
inactive_repos=inactive,
|
||||||
"active_repos": list(active),
|
archived_repos=archived,
|
||||||
"inactive_repos": list(inactive),
|
stats=get_stats(),
|
||||||
"stats": {k: dict(v) for k, v in stats.items()},
|
COLUMN_LABELS=COLUMN_LABELS,
|
||||||
"COLUMN_LABELS": dict(COLUMN_LABELS),
|
COLUMN_COLORS=COLUMN_COLORS,
|
||||||
"COLUMN_COLORS": dict(COLUMN_COLORS),
|
active_window_days=get_settings().active_window_days,
|
||||||
}
|
)
|
||||||
return render_template("dashboard.html", **ctx)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
|
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
|
||||||
async def board(request: Request, owner: str, repo: str):
|
async def board(request: Request, owner: str, repo: str):
|
||||||
"""Kanban board for a specific repo."""
|
"""Kanban board for a specific repo."""
|
||||||
full_name = f"{owner}/{repo}"
|
full_name = f"{owner}/{repo}"
|
||||||
issues = gitea.get_repo_issues(owner, repo, state="open")
|
all_items = await gitea.get_repo_issues(owner, repo, state="all")
|
||||||
board_state = get_board_state(full_name)
|
issues = [i for i in all_items if _is_issue(i)]
|
||||||
columns = _merge_issues_with_board(issues, board_state)
|
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 = {}
|
repo_info = {}
|
||||||
try:
|
try:
|
||||||
import httpx
|
repo_info = await gitea.get_repo(owner, repo)
|
||||||
client = gitea.client
|
except Exception as exc: # non-critical decoration
|
||||||
r = client.get(f"/repos/{owner}/{repo}")
|
logger.debug("repo info failed for %s: %s", full_name, exc)
|
||||||
if r.status_code == 200:
|
|
||||||
repo_info = r.json()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return render_template("board.html",
|
return render(
|
||||||
|
request,
|
||||||
|
"board.html",
|
||||||
owner=owner,
|
owner=owner,
|
||||||
repo=repo,
|
repo=repo,
|
||||||
full_name=full_name,
|
full_name=full_name,
|
||||||
columns=columns,
|
columns=columns,
|
||||||
column_labels=COLUMN_LABELS,
|
column_labels=COLUMN_LABELS,
|
||||||
column_colors=COLUMN_COLORS,
|
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,
|
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 ────────────────────────────────────────────
|
# ── Routes: API ────────────────────────────────────────────
|
||||||
|
|
||||||
@app.post("/api/move")
|
@app.post("/api/move")
|
||||||
async def api_move(
|
async def api_move(
|
||||||
|
request: Request,
|
||||||
repo_full_name: str = Form(...),
|
repo_full_name: str = Form(...),
|
||||||
issue_number: int = Form(...),
|
issue_number: int = Form(...),
|
||||||
to_column: str = 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:
|
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)
|
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")
|
@app.post("/api/notes")
|
||||||
async def api_notes(
|
async def api_notes(
|
||||||
|
request: Request,
|
||||||
repo_full_name: str = Form(...),
|
repo_full_name: str = Form(...),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
):
|
):
|
||||||
"""Save project notes."""
|
"""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)
|
set_notes(repo_full_name, note)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -179,14 +522,29 @@ async def api_stats():
|
|||||||
|
|
||||||
@app.get("/api/projects")
|
@app.get("/api/projects")
|
||||||
async def api_projects():
|
async def api_projects():
|
||||||
"""Get all projects (JSON)."""
|
"""Get all projects (JSON), classified like the dashboard."""
|
||||||
repos = gitea.search_repos()
|
repos = await gitea.search_repos()
|
||||||
return {
|
active, inactive, archived = classify_repos(repos)
|
||||||
"active": [r for r in repos if _is_active(r)],
|
return {"active": active, "inactive": inactive, "archived": archived}
|
||||||
"inactive": [r for r in repos if not _is_active(r)],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def 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,
|
||||||
|
)
|
||||||
|
|||||||
+207
-69
@@ -1,64 +1,132 @@
|
|||||||
"""
|
"""
|
||||||
SQLite models for Kanbante — lightweight local state.
|
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
|
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"]
|
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "review", "done"]
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> sqlite3.Connection:
|
def db_path() -> str:
|
||||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
"""Resolve the database path from the environment (per call)."""
|
||||||
conn = sqlite3.connect(DB_PATH)
|
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.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
return conn
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
conn = get_db()
|
with get_db() as conn:
|
||||||
conn.executescript("""
|
conn.executescript(
|
||||||
CREATE TABLE IF NOT EXISTS board_state (
|
"""
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
CREATE TABLE IF NOT EXISTS board_state (
|
||||||
repo_full_name TEXT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
issue_number INTEGER NOT NULL,
|
repo_full_name TEXT NOT NULL,
|
||||||
column_name TEXT NOT NULL DEFAULT 'backlog',
|
issue_number INTEGER NOT NULL,
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
column_name TEXT NOT NULL DEFAULT 'backlog',
|
||||||
UNIQUE(repo_full_name, issue_number)
|
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 (
|
CREATE TABLE IF NOT EXISTS project_notes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
repo_full_name TEXT NOT NULL,
|
repo_full_name TEXT NOT NULL,
|
||||||
note TEXT NOT NULL,
|
note TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
UNIQUE(repo_full_name)
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
);
|
UNIQUE(repo_full_name)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_board_repo
|
CREATE INDEX IF NOT EXISTS idx_board_repo
|
||||||
ON board_state(repo_full_name, column_name);
|
ON board_state(repo_full_name, column_name);
|
||||||
""")
|
"""
|
||||||
conn.commit()
|
)
|
||||||
conn.close()
|
_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]]:
|
def get_board_state(repo_full_name: str) -> dict[str, list[dict]]:
|
||||||
"""Return board columns with their issues."""
|
"""Return board columns with their issues, ordered by position."""
|
||||||
conn = get_db()
|
with get_db() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT issue_number, column_name, updated_at FROM board_state WHERE repo_full_name = ?",
|
"SELECT issue_number, column_name, position, updated_at "
|
||||||
(repo_full_name,),
|
"FROM board_state WHERE repo_full_name = ? "
|
||||||
).fetchall()
|
"ORDER BY position ASC, issue_number ASC",
|
||||||
conn.close()
|
(repo_full_name,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
|
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
|
||||||
for row in rows:
|
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"],
|
"issue_number": row["issue_number"],
|
||||||
|
"position": row["position"],
|
||||||
"updated_at": row["updated_at"],
|
"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:
|
def move_issue(repo_full_name: str, issue_number: int, to_column: str) -> None:
|
||||||
"""Move an issue to a different column."""
|
"""Move an issue to a different column (appended at the end)."""
|
||||||
conn = get_db()
|
if to_column not in KANBAN_COLUMNS:
|
||||||
conn.execute(
|
raise ValueError(f"Invalid column: {to_column}")
|
||||||
"""INSERT INTO board_state (repo_full_name, issue_number, column_name, updated_at)
|
with get_db() as conn:
|
||||||
VALUES (?, ?, ?, datetime('now'))
|
row = conn.execute(
|
||||||
ON CONFLICT(repo_full_name, issue_number)
|
"SELECT COALESCE(MAX(position), -1) + 1 AS next_pos FROM board_state "
|
||||||
DO UPDATE SET column_name = excluded.column_name, updated_at = excluded.updated_at""",
|
"WHERE repo_full_name = ? AND column_name = ?",
|
||||||
(repo_full_name, issue_number, to_column),
|
(repo_full_name, to_column),
|
||||||
)
|
).fetchone()
|
||||||
conn.commit()
|
conn.execute(
|
||||||
conn.close()
|
"""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:
|
def set_notes(repo_full_name: str, note: str) -> None:
|
||||||
conn = get_db()
|
with get_db() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO project_notes (repo_full_name, note)
|
"""INSERT INTO project_notes (repo_full_name, note)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
ON CONFLICT(repo_full_name)
|
ON CONFLICT(repo_full_name)
|
||||||
DO UPDATE SET note = excluded.note""",
|
DO UPDATE SET note = excluded.note,
|
||||||
(repo_full_name, note),
|
updated_at = datetime('now')""",
|
||||||
)
|
(repo_full_name, note),
|
||||||
conn.commit()
|
)
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def get_notes(repo_full_name: str) -> str | None:
|
def get_notes(repo_full_name: str) -> str | None:
|
||||||
conn = get_db()
|
with get_db() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT note FROM project_notes WHERE repo_full_name = ?",
|
"SELECT note FROM project_notes WHERE repo_full_name = ?",
|
||||||
(repo_full_name,),
|
(repo_full_name,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
conn.close()
|
|
||||||
return row["note"] if row else None
|
return row["note"] if row else None
|
||||||
|
|
||||||
|
|
||||||
def get_stats() -> dict:
|
def get_stats() -> dict:
|
||||||
"""Get overall stats across all projects."""
|
"""Get overall stats across all projects."""
|
||||||
conn = get_db()
|
with get_db() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT repo_full_name, column_name, COUNT(*) as cnt FROM board_state GROUP BY repo_full_name, column_name"
|
"SELECT repo_full_name, column_name, COUNT(*) as cnt "
|
||||||
).fetchall()
|
"FROM board_state GROUP BY repo_full_name, column_name"
|
||||||
conn.close()
|
).fetchall()
|
||||||
|
|
||||||
projects: dict[str, dict[str, int]] = {}
|
projects: dict[str, dict[str, int]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
if row["column_name"] not in KANBAN_COLUMNS:
|
||||||
|
continue
|
||||||
name = row["repo_full_name"]
|
name = row["repo_full_name"]
|
||||||
if name not in projects:
|
if name not in projects:
|
||||||
projects[name] = {col: 0 for col in KANBAN_COLUMNS}
|
projects[name] = {col: 0 for col in KANBAN_COLUMNS}
|
||||||
|
|||||||
Reference in New Issue
Block a user