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:
Spark
2026-10-11 09:31:48 -04:00
parent eb4f8d8f4d
commit d267df01fa
4 changed files with 921 additions and 191 deletions
+87
View File
@@ -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
View File
@@ -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()
+440 -82
View File
@@ -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,
)
+207 -69
View File
@@ -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}