Files
kanbante/app/main.py
T
Spark 13182f5d53
CI / test (push) Successful in 1m56s
0.25.0 — Jetons d'API personnels (kbt_) pour les surfaces machine
Depuis 0.24.0, /mcp exige session ou Basic : le service d'agents s'y
faisait refuser en 401 (vu en production, panneau Spark du 2026-10-21).

- Table api_tokens (empreinte SHA-256 seule), CRUD dans Mon profil
- Bearer honoré par le middleware sur /mcp et /api/ uniquement —
  jamais l'interface web ; révocation et désactivation immédiates
- 235 tests verts (3 nouveaux : cycle de vie, compte désactivé,
  handshake MCP par jeton quand l'auth est active)
2026-10-21 21:38:16 -04:00

5743 lines
199 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Kanbante — Kanban project manager integrated with Gitea.
FastAPI backend serving HTML + JSON API.
0.2.0 highlights (see CHANGELOG.md):
- Optional HTTP Basic auth (KANBANTE_AUTH_USER / _PASSWORD) + CSRF
double-submit protection on every POST.
- Two-way sync: moving a card to "done" closes the Gitea issue,
moving it back reopens it (KANBANTE_SYNC_GITEA=0 disables).
- Repos are classified with a rolling activity window and Gitea's real
"archived" flag, not a hardcoded year.
- Board state is reconciled with Gitea on every board load, drag order
is persisted, and Gitea failures render a proper 502 error page
instead of a bare 500.
"""
import asyncio
import base64
import hashlib
import hmac
import json
import logging
import re
import secrets
import time
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import quote
import httpx
from fastapi import FastAPI, Form, Request
from fastapi.responses import (
HTMLResponse,
JSONResponse,
RedirectResponse,
Response,
StreamingResponse,
)
from fastapi.staticfiles import StaticFiles
from jinja2 import Environment, FileSystemLoader, select_autoescape
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import APP_VERSION, get_settings
from app.gitea_client import GiteaClient
from app import code_links as code_links_service
from app import digest as digest_service
from app import snapshots as snapshots_service
from app import flow as flow_service
from app import forecast as forecast_service
from app import rules as rules_service
from app import sprint as sprint_service
from app import connections as connections_service
from app.briefing import (
format_briefing_text,
format_standup_text,
ntfy_configured,
send_ntfy,
)
from app.capture import ntfy_capture_loop, parse_capture_text
from app import models
from app.models import (
KANBAN_COLUMNS,
add_pick,
column_entry_times,
get_board_state,
get_events,
get_kv,
get_metrics,
get_notes,
get_picks,
get_project_activity,
get_stats,
init_db,
log_event,
move_issue,
ping_db,
reconcile_board_state,
remove_pick,
set_column_order,
set_kv,
set_notes,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("kanbante")
BASE = Path(__file__).resolve().parent.parent
CSRF_COOKIE = "kanbante_csrf"
COLUMN_LABELS = {
"backlog": "📥 Backlog",
"todo": "📋 À faire",
"in_progress": "🚧 En cours",
"qa": "🧪 QA",
"review": "🔍 Révision",
"done": "✅ Terminé",
}
COLUMN_COLORS = {
"backlog": "#6b7280",
"todo": "#3b82f6",
"in_progress": "#f59e0b",
"qa": "#06b6d4",
"review": "#8b5cf6",
"done": "#10b981",
}
FOCUS_COLUMNS = ["in_progress", "qa", "review"]
# Columns mirrored to Gitea as kanban:<column> labels (backlog and
# done carry none: done is the closed state itself).
LABELLED_COLUMNS = ("todo", "in_progress", "qa", "review")
EVENT_ICONS = {
"created": "➕",
"moved": "➡️",
"closed": "✅",
"reopened": "🔁",
"commented": "💬",
}
# ── App setup ──────────────────────────────────────────────
async def _briefing_scheduler() -> None:
"""Send the daily briefing to ntfy at KANBANTE_BRIEFING_TIME.
Checks once a minute; the kv store remembers the last day sent so
restarts never double-send.
"""
while True:
try:
settings = get_settings()
if settings.briefing_time and ntfy_configured(settings):
now = datetime.now()
today = now.strftime("%Y-%m-%d")
if (
now.strftime("%H:%M") >= settings.briefing_time
and get_kv("briefing_last_sent") != today
):
data = await _briefing_data()
if await send_ntfy(settings, format_briefing_text(data)):
set_kv("briefing_last_sent", today)
logger.info("Daily briefing sent to ntfy (%s)", today)
except asyncio.CancelledError:
raise
except Exception as exc: # never let the scheduler die silently
logger.warning("Briefing scheduler error: %s", exc)
await asyncio.sleep(60)
@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."
)
# 0.24.0 (P0/P2/P3): the env config becomes connection #1
# and the Principal workspace; Bruno's env login becomes the
# first database admin; snapshots written before connections
# existed are re-keyed under the "c1:" prefix — all without
# any reconfiguration on upgrade.
legacy = connections_service.ensure_legacy_connection()
connections_service.ensure_principal_workspace()
connections_service.prefix_legacy_snapshots(legacy["id"])
registry.build(gitea)
from app import accounts as accounts_service
admin = accounts_service.ensure_bootstrap_admin()
if admin is not None:
principal = models.list_workspaces()[0]
models.set_member(principal["id"], admin["id"], "admin")
logger.info("bootstrap admin created: %s", admin["username"])
# Chantier B: hydrate the client caches from persisted
# snapshots BEFORE serving, then keep them warm in the
# background — pages never wait on a forge when data exists.
for client in registry.global_clients():
client.hydrate_from_snapshots()
snapshot_task = asyncio.create_task(
snapshots_service.snapshot_loop(
registry.global_clients, classify_repos
)
)
scheduler = asyncio.create_task(_briefing_scheduler())
digest_task = asyncio.create_task(_digest_scheduler())
rules_task = asyncio.create_task(_rules_scheduler())
capture_task = None
if settings.capture_ntfy_topic and settings.ntfy_url:
async def _capture_create(title: str, body: str) -> None:
repo = get_settings().capture_repo
if not repo:
logger.warning("ntfy capture: no KANBANTE_CAPTURE_REPO, skipped")
return
await _do_capture(title, body, repo, "backlog")
capture_task = asyncio.create_task(
ntfy_capture_loop(
_capture_create,
lambda: get_kv("capture_ntfy_last_id"),
lambda value: set_kv("capture_ntfy_last_id", value),
)
)
logger.info(
"ntfy capture listener started (topic %s)",
settings.capture_ntfy_topic,
)
yield
snapshot_task.cancel()
scheduler.cancel()
digest_task.cancel()
rules_task.cancel()
if capture_task is not None:
capture_task.cancel()
await gitea.aclose()
app = FastAPI(title="Kanbante", version=APP_VERSION, lifespan=lifespan)
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
gitea = GiteaClient()
registry = connections_service.ForgeRegistry()
# ── Auth: login form + signed session cookie (0.22.0) ──────
# Before 0.22.0 the browser got HTTP Basic's native prompt.
# Browsers now use a real login page; the session rides in an
# HMAC-signed cookie. An Authorization: Basic header is still
# honoured for scripts and API clients. /api/health stays
# public for Docker's HEALTHCHECK; /webhooks/gitea is
# authenticated by its HMAC signature (Gitea cannot log in) and
# /api/capture by its dedicated token.
SESSION_COOKIE = "kanbante_session"
SESSION_DAYS = 7
_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login",
"/auth/draco")
def _session_secret() -> bytes:
settings = get_settings()
if settings.session_secret:
return settings.session_secret.encode("utf-8")
# Derived from the credentials: rotating the password
# invalidates every outstanding session (documented trade-off).
material = f"kanbante-session:{settings.auth_user}:{settings.auth_password}"
return hashlib.sha256(material.encode("utf-8")).digest()
def _make_session(user: str) -> str:
"""Signed session cookie (0.22.0): user.expiry.hmac."""
exp = int(time.time()) + SESSION_DAYS * 86400
sig = hmac.new(
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
).hexdigest()
return f"{user}.{exp}.{sig}"
def _session_user(request: Request) -> str | None:
raw = request.cookies.get(SESSION_COOKIE, "")
user, _, rest = raw.partition(".")
exp, _, sig = rest.partition(".")
if not user or not exp or not sig:
return None
expected = hmac.new(
_session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256
).hexdigest()
if not secrets.compare_digest(sig, expected):
return None
try:
if int(exp) < int(time.time()):
return None
except ValueError:
return None
# 0.24.0 : la session peut appartenir à n'importe quel
# compte de la base (résolu ensuite par _current_user) ou,
# en mode env pur, à l'opérateur d'environnement.
row = models.get_user_by_username(user)
if row is not None:
return user
if secrets.compare_digest(user, get_settings().auth_user):
return user
return None
def _basic_ok(request: Request) -> bool:
settings = get_settings()
header = request.headers.get("authorization", "")
if not header.lower().startswith("basic "):
return False
try:
decoded = base64.b64decode(header[6:]).decode("utf-8")
except Exception:
return False
user, _, password = decoded.partition(":")
return secrets.compare_digest(
user, settings.auth_user
) and secrets.compare_digest(password, settings.auth_password)
def _bearer_user(request: Request) -> dict | None:
"""Jeton d'API personnel (0.25.0) : Authorization: Bearer kbt_…
Réservé aux surfaces machine (/mcp, /api/) — le middleware ne
l'honore que là. La résolution vérifie l'empreinte, la révocation
et que le compte est actif, et journalise l'usage (last_used_at)."""
header = request.headers.get("authorization", "")
if not header.lower().startswith("bearer "):
return None
return models.user_by_api_token(header[7:].strip())
def _current_user(request: Request) -> dict | None:
"""The database user behind the session, or None.
Falls back to a synthetic admin row for the environment
operator when no users table entry matches (pure env mode,
pre-bootstrap). A disabled database user resolves to None:
the middleware treats the session as unauthenticated.
"""
username = _session_user(request)
if username is None:
return None
row = models.get_user_by_username(username)
if row is not None:
return row if row["active"] else None
settings = get_settings()
if settings.auth_enabled and username == settings.auth_user:
return {
"id": None,
"username": username,
"role": "admin",
"display_name": username,
"draco_sub": None,
"active": 1,
"must_change_password": 0,
}
return None
class AuthMiddleware(BaseHTTPMiddleware):
"""Session cookie (browser) or Basic header (scripts)."""
async def dispatch(self, request: Request, call_next):
path = request.url.path
if (
path in _PUBLIC_PATHS
or path.startswith("/static/")
or path.startswith("/webhooks/")
):
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)
session_user = _session_user(request)
if session_user is not None:
row = models.get_user_by_username(session_user)
if row is not None and not row["active"]:
session_user = None # disabled account: session is dead
if session_user is not None or _basic_ok(request):
return await call_next(request)
# Jetons d'API (0.25.0) : surfaces machine seulement — un jeton
# ne déverrouille jamais l'interface web.
if (path.startswith("/mcp") or path.startswith("/api/")) \
and _bearer_user(request) is not None:
return await call_next(request)
# API consumers get a plain 401 (no browser prompt, no
# redirect they cannot follow); browsers get the login page.
if path.startswith("/api/") or "text/html" not in request.headers.get(
"accept", ""
):
return JSONResponse(
{"ok": False, "error": "Authentication required"},
status_code=401,
)
target = path
if request.url.query:
target = f"{path}?{request.url.query}"
return RedirectResponse(f"/login?next={quote(target, safe='')}", status_code=303)
app.add_middleware(AuthMiddleware)
def _safe_next(raw: str) -> str:
"""Only local paths may be a post-login destination."""
if raw.startswith("/") and not raw.startswith("//") and "://" not in raw:
return raw
return "/"
@app.get("/login")
async def login_page(request: Request, next: str = "/"):
if not get_settings().auth_enabled or _session_user(request) is not None:
return RedirectResponse(_safe_next(next), status_code=303)
return render(request, "login.html", next=_safe_next(next), error="")
@app.post("/login")
async def login_submit(
request: Request,
username: str = Form(""),
password: str = Form(""),
next: str = Form("/"),
csrf_token: str = Form(""),
):
settings = get_settings()
if not settings.auth_enabled:
return RedirectResponse("/", status_code=303)
if not _csrf_valid(request, csrf_token):
return render(
request, "login.html", status_code=403,
next=_safe_next(next), error="Session expirée — réessaie.",
)
from app import accounts as accounts_service
user = models.get_user_by_username(username)
ok = False
if user is not None and user["active"]:
ok = accounts_service.verify_password(password, user["password_hash"])
if not ok and username == settings.auth_user:
# Emergency fallback: the environment credentials always
# work for the operator account (D6) — they are also what
# created the bootstrap admin, so the two never diverge in
# practice unless the DB password was changed since.
ok = secrets.compare_digest(
password, settings.auth_password
) and (user is None or user["role"] == "admin")
if ok and user is not None and user["active"]:
models.log_admin(user["id"], "login.env_fallback", username)
if not ok:
return render(
request, "login.html", status_code=401,
next=_safe_next(next), error="Identifiants incorrects.",
)
if user is not None:
models.update_user(
user["id"],
last_login_at=time.strftime(
"%Y-%m-%d %H:%M:%S", time.gmtime()
),
)
# First doubly-connected visit: link the draco identity
# to this account (same mechanics as the other apps).
from app import draco
claims = draco.claims_for(request)
if claims and not user.get("draco_sub"):
sub = claims.get("sub")
if sub and models.get_user_by_draco(sub) is None:
models.update_user(user["id"], draco_sub=sub)
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(username),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
@app.get("/logout")
async def logout():
response = RedirectResponse("/login", status_code=303)
response.delete_cookie(SESSION_COOKIE)
return response
@app.get("/auth/draco")
def draco_bridge(request: Request, next: str = "/"):
"""Fusion ACC-3.2 — pont SSO : cookie ``draco_session`` → session.
Point de retour de ``GET /sso/authorize`` de l'émettrice
(ObsiGate). Depuis 0.24.0 la liaison est PAR UTILISATEUR :
si le ``sub`` draco est lié à un compte, sa session est
créée ; si une session native est déjà ouverte, le ``sub``
est lié à ce compte (première visite doublement connectée) ;
sans compte lié, retour au login — la connexion native qui
suit crée le lien (voir login_submit). Sans utilisateurs en
base, comportement historique : session de l'opérateur.
Inerte sans ``DRACO_SESSION_SECRET``.
"""
from app import draco
settings = get_settings()
claims = draco.claims_for(request)
if not settings.auth_enabled or claims is None:
return RedirectResponse("/login", status_code=303)
sub = claims.get("sub")
# Déjà connecté nativement : lier le sub à ce compte.
current = _current_user(request)
if current is not None and current.get("id") is not None:
if sub and not current.get("draco_sub"):
if models.get_user_by_draco(sub) is None:
models.update_user(current["id"], draco_sub=sub)
models.log_admin(current["id"], "draco.link", str(sub))
return RedirectResponse(_safe_next(next), status_code=303)
# Compte lié à ce sub : ouvrir sa session.
linked = models.get_user_by_draco(sub) if sub else None
if linked is not None and linked["active"]:
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(linked["username"]),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
if models.count_users() == 1:
# Mono-utilisateur (cas historique) : le premier sub
# valide se lie au compte unique et ouvre sa session —
# c'est la sémantique livrée en 0.23.0, conservée tant
# qu'il n'y a qu'un seul compte.
sole = models.list_users()[0]
if sole["active"]:
if sub and not sole.get("draco_sub"):
if models.get_user_by_draco(sub) is None:
models.update_user(sole["id"], draco_sub=sub)
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(sole["username"]),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
if models.count_users() == 0:
# Mode env pur (aucun compte) : session de l'opérateur.
response = RedirectResponse(_safe_next(next), status_code=303)
response.set_cookie(
SESSION_COOKIE,
_make_session(settings.auth_user),
max_age=SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
)
return response
return RedirectResponse("/login", status_code=303)
# ── Archives de projets (0.24.0, P4) ──────────────────────
def _archive_rows(request: Request, repos: list[dict], workspace) -> dict:
"""Classify repos for the /archives page.
Three states (analysis §5.1): forge-archived (the forge's
own flag), locally archived (hidden in this workspace only),
and "à archiver ?" — alive but idle longer than the
archive_suggest_days setting.
"""
ws_id = workspace["id"] if workspace else 1
local = {
(r["connection_id"], r["repo_full_name"]): r
for r in models.list_local_archived(ws_id)
}
settings = get_settings()
today = datetime.now(timezone.utc).date()
out = {"suggested": [], "local": [], "forge": [], "others": []}
seen: set[tuple[int, str]] = set()
for repo in repos:
cid = repo.get("_conn_id", registry.default_id)
key = (cid, repo["full_name"])
seen.add(key)
updated = _parse_datetime(str(repo.get("updated_at") or ""))
idle_days = (
(today - updated.date()).days if updated is not None else None
)
row = {
"full_name": repo["full_name"],
"conn_id": cid,
"conn_slug": repo.get("_conn_slug", ""),
"conn_kind": repo.get("_conn_kind", "gitea"),
"open_issues": repo.get("open_issues_count") or 0,
"idle_days": idle_days,
"updated_at": (repo.get("updated_at") or "")[:10],
}
if repo.get("archived"):
out["forge"].append(row)
elif key in local:
row["since"] = local[key].get("updated_at", "")[:10]
out["local"].append(row)
elif (
idle_days is not None
and idle_days >= settings.archive_suggest_days
):
out["suggested"].append(row)
else:
out["others"].append(row)
# Archives locales dont le dépôt a disparu de la forge :
# les garder visibles (restaurer/purger reste possible).
for key, state in local.items():
if key not in seen:
out["local"].append(
{
"full_name": state["repo_full_name"],
"conn_id": key[0],
"conn_slug": "",
"conn_kind": "gitea",
"open_issues": 0,
"idle_days": None,
"updated_at": "",
"since": state.get("updated_at", "")[:10],
"missing": True,
}
)
return out
@app.get("/archives", response_class=HTMLResponse)
async def archives_page(request: Request, msg: str = "", err: str = ""):
workspace, _role = _current_workspace(request)
repos, _ws = await _workspace_repos(request, include_local_archived=True)
groups = _archive_rows(request, repos, workspace)
return render(
request, "archives.html",
groups=groups,
workspace=workspace,
suggest_days=get_settings().archive_suggest_days,
can_write=_workspace_role_for(
workspace["id"] if workspace else 1, _current_user(request)
) != "reader",
msg=msg, err=err,
)
async def _archive_target(request: Request, conn_id: int, repo_full_name: str):
"""(client, workspace, owner, repo) or None for an archive action."""
workspace, _role = _current_workspace(request)
parts = _split_full_name(repo_full_name)
if workspace is None or parts is None:
return None
if conn_id not in models.workspace_connection_ids(workspace["id"]):
return None
client = _forge_client(conn_id)
return client, workspace, parts[0], parts[1]
@app.post("/archives/local")
async def archives_local(
request: Request,
conn_id: int = Form(0),
repo_full_name: str = Form(""),
archived: str = Form("1"),
csrf_token: str = Form(""),
):
if not _csrf_valid(request, csrf_token):
return _csrf_error()
target = await _archive_target(request, conn_id, repo_full_name)
if target is None:
return RedirectResponse("/archives?err=Cible+inconnue", status_code=303)
_client, workspace, _o, _r = target
models.set_local_archived(
workspace["id"], conn_id, repo_full_name, archived == "1"
)
user = _current_user(request)
models.log_admin(
user.get("id") if user else None,
"archive.local" if archived == "1" else "archive.restore",
f"{repo_full_name} (espace {workspace['slug']})",
)
return RedirectResponse(
"/archives?msg=" + ("Projet+archiv%C3%A9+localement" if archived == "1" else "Projet+restaur%C3%A9"),
status_code=303,
)
@app.post("/archives/local-bulk")
async def archives_local_bulk(request: Request, csrf_token: str = Form("")):
"""Archive locally every checked suggestion (one shot)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
workspace, _role = _current_workspace(request)
if workspace is None:
return RedirectResponse("/archives", status_code=303)
form = await request.form()
count = 0
for raw in form.getlist("item"):
cid_raw, _, full_name = str(raw).partition(":")
try:
cid = int(cid_raw)
except ValueError:
continue
if cid not in models.workspace_connection_ids(workspace["id"]):
continue
models.set_local_archived(workspace["id"], cid, full_name, True)
count += 1
user = _current_user(request)
models.log_admin(
user.get("id") if user else None,
"archive.local.bulk", f"{count} projets (espace {workspace['slug']})",
)
from urllib.parse import quote as _q
return RedirectResponse(
f"/archives?msg={_q(f'{count} projet(s) archivés localement')}",
status_code=303,
)
@app.post("/archives/forge")
async def archives_forge(
request: Request,
conn_id: int = Form(0),
repo_full_name: str = Form(""),
archived: str = Form("1"),
csrf_token: str = Form(""),
):
"""Archive / unarchive ON THE FORGE (explicit, confirmed in UI)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
target = await _archive_target(request, conn_id, repo_full_name)
if target is None:
return RedirectResponse("/archives?err=Cible+inconnue", status_code=303)
client, workspace, owner, repo = target
try:
await client.set_archived(owner, repo, archived == "1")
except Exception as exc:
from urllib.parse import quote as _q
return RedirectResponse(
f"/archives?err={_q('Échec sur la forge : ' + str(exc))}",
status_code=303,
)
user = _current_user(request)
models.log_admin(
user.get("id") if user else None,
"archive.forge" if archived == "1" else "archive.forge.restore",
repo_full_name,
)
return RedirectResponse(
"/archives?msg=" + ("Projet+archiv%C3%A9+sur+la+forge" if archived == "1" else "Projet+d%C3%A9sarchiv%C3%A9+sur+la+forge"),
status_code=303,
)
@app.post("/archives/purge")
async def archives_purge(
request: Request,
conn_id: int = Form(0),
repo_full_name: str = Form(""),
csrf_token: str = Form(""),
):
"""Drop the LOCAL state of a project in this workspace.
Board, notes and today's picks of this workspace only —
the forge is never touched.
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
target = await _archive_target(request, conn_id, repo_full_name)
if target is None:
return RedirectResponse("/archives?err=Cible+inconnue", status_code=303)
_client, workspace, _o, _r = target
models.purge_project_local_state(workspace["id"], conn_id, repo_full_name)
user = _current_user(request)
models.log_admin(
user.get("id") if user else None,
"archive.purge", f"{repo_full_name} (espace {workspace['slug']})",
)
return RedirectResponse(
"/archives?msg=%C3%89tat+local+purg%C3%A9", status_code=303
)
@app.post("/archives/export")
async def archives_export(
request: Request,
conn_id: int = Form(0),
repo_full_name: str = Form(""),
csrf_token: str = Form(""),
):
"""A Markdown recap of the project (board, notes, journal tail)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
target = await _archive_target(request, conn_id, repo_full_name)
if target is None:
return RedirectResponse("/archives?err=Cible+inconnue", status_code=303)
_client, workspace, owner, repo = target
board = get_board_state(
repo_full_name, workspace_id=workspace["id"], connection_id=conn_id
)
lines = [
f"# {repo_full_name}",
"",
f"Espace : {workspace['name']} · Export du "
f"{datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
"## Board",
"",
]
for column in KANBAN_COLUMNS:
cards = board.get(column) or []
lines.append(f"### {COLUMN_LABELS[column]} ({len(cards)})")
for card in cards:
lines.append(f"- #{card['issue_number']}")
lines.append("")
notes = get_notes(
repo_full_name, workspace_id=workspace["id"], connection_id=conn_id
)
if notes:
lines += ["## Notes du projet", "", notes, ""]
events = [
e for e in get_events(repo_full_name=repo_full_name, limit=200)
if e.get("connection_id") == conn_id
and e.get("workspace_id") == workspace["id"]
]
if events:
lines += ["## Journal (récent)", ""]
for e in events[:50]:
lines.append(
f"- {e['created_at']} · {e['event_type']} · "
f"#{e['issue_number']}"
)
from fastapi.responses import PlainTextResponse
return PlainTextResponse(
"\n".join(lines),
media_type="text/markdown",
headers={
"Content-Disposition": (
f'attachment; filename="{owner}-{repo}-recap.md"'
)
},
)
# ── Espaces de travail : résolution (0.24.0, P3) ──────────
WORKSPACE_COOKIE = "kanbante_workspace"
def _workspace_role_for(workspace_id: int, user: dict | None) -> str | None:
if user is None or user.get("id") is None:
return "admin" # mode env / auth désactivée : accès complet
if user.get("role") == "admin":
return "admin"
return models.workspace_role(workspace_id, user["id"])
def _current_workspace(request: Request):
"""(workspace, role) for this request.
The kanbante_workspace cookie wins when the user may see
that space; otherwise the first accessible space (Principal
first). Global admins and the env operator see everything.
"""
workspaces = models.list_workspaces()
if not workspaces:
return None, None
user = _current_user(request)
chosen = request.cookies.get(WORKSPACE_COOKIE, "")
for ws in workspaces:
if str(ws["id"]) == chosen:
role = _workspace_role_for(ws["id"], user)
if role is not None:
return ws, role
for ws in workspaces:
role = _workspace_role_for(ws["id"], user)
if role is not None:
return ws, role
return workspaces[0], None
@app.post("/workspace/switch")
async def workspace_switch(
request: Request,
workspace_id: int = Form(0),
csrf_token: str = Form(""),
):
if not _csrf_valid(request, csrf_token):
return _csrf_error()
workspace, role = None, None
user = _current_user(request)
target = models.get_workspace(workspace_id)
if target is not None:
role = _workspace_role_for(target["id"], user)
if role is not None:
workspace = target
response = RedirectResponse(
request.headers.get("referer") or "/", status_code=303
)
if workspace is not None:
response.set_cookie(
WORKSPACE_COOKIE, str(workspace["id"]),
max_age=365 * 86400, samesite="lax",
)
return response
# ── Administration et profils (0.24.0, P2) ────────────────
def _require_admin(request: Request) -> dict | None:
user = _current_user(request)
if user is None or user.get("role") != "admin":
return None
return user
def _admin_denied(request: Request):
return render(
request, "error.html", status_code=403,
message="Réservé aux administrateurs.",
)
def _slugify(text: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
return slug or "connexion"
def _unique_slug(base: str, table: str) -> str:
slug, n = base, 2
getter = (
models.get_connection_by_slug
if table == "connections"
else None
)
while getter is not None and getter(slug) is not None:
slug = f"{base}-{n}"
n += 1
return slug
@app.get("/admin")
async def admin_home(request: Request, msg: str = "", err: str = ""):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
return render(
request, "admin.html",
users=models.list_users(),
audit=models.list_admin_audit(50),
connections=models.list_connections(),
workspaces=models.list_workspaces(),
msg=msg, err=err,
)
@app.post("/admin/users")
async def admin_create_user(
request: Request,
username: str = Form(""),
password: str = Form(""),
role: str = Form("member"),
display_name: str = Form(""),
csrf_token: str = Form(""),
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
username = username.strip()
if not username or not password:
return RedirectResponse(
"/admin?err=Nom+d%27utilisateur+et+mot+de+passe+requis",
status_code=303,
)
if models.get_user_by_username(username) is not None:
return RedirectResponse(
"/admin?err=Utilisateur+d%C3%A9j%C3%A0+existant", status_code=303
)
from app import accounts as accounts_service
user_id = models.create_user(
username,
accounts_service.hash_password(password),
role="admin" if role == "admin" else "member",
display_name=display_name.strip(),
must_change_password=1,
)
# Tout nouvel utilisateur rejoint l'espace Principal comme membre.
principal = models.list_workspaces()
if principal:
models.set_member(principal[0]["id"], user_id, "member")
models.log_admin(admin.get("id"), "user.create", username)
return RedirectResponse("/admin?msg=Utilisateur+cr%C3%A9%C3%A9", status_code=303)
@app.post("/admin/users/{user_id}/toggle")
async def admin_toggle_user(
request: Request, user_id: int, csrf_token: str = Form("")
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
target = models.get_user(user_id)
if target is not None and admin.get("id") != user_id:
models.update_user(user_id, active=0 if target["active"] else 1)
models.log_admin(
admin.get("id"),
"user.enable" if not target["active"] else "user.disable",
target["username"],
)
return RedirectResponse("/admin", status_code=303)
@app.post("/admin/users/{user_id}/role")
async def admin_role_user(
request: Request,
user_id: int,
role: str = Form("member"),
csrf_token: str = Form(""),
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
target = models.get_user(user_id)
if target is not None and admin.get("id") != user_id:
models.update_user(
user_id, role="admin" if role == "admin" else "member"
)
models.log_admin(admin.get("id"), "user.role", target["username"])
return RedirectResponse("/admin", status_code=303)
@app.post("/admin/users/{user_id}/reset")
async def admin_reset_user(
request: Request,
user_id: int,
password: str = Form(""),
csrf_token: str = Form(""),
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
target = models.get_user(user_id)
if target is not None and password:
from app import accounts as accounts_service
models.update_user(
user_id,
password_hash=accounts_service.hash_password(password),
must_change_password=1,
)
models.log_admin(admin.get("id"), "user.reset", target["username"])
return RedirectResponse("/admin?msg=Mot+de+passe+r%C3%A9initialis%C3%A9", status_code=303)
@app.get("/admin/connections")
async def admin_connections(request: Request, msg: str = "", err: str = ""):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
rows = []
for row in models.list_connections():
row = dict(row)
from app import forge_crypto
row["token_mask"] = forge_crypto.mask_token(row["token_encrypted"])
row["used_by"] = [
ws["name"]
for ws in models.list_workspaces()
if row["id"] in models.workspace_connection_ids(ws["id"])
]
rows.append(row)
return render(
request, "admin_connections.html", connections=rows, msg=msg, err=err
)
async def _test_connection_row(row: dict) -> tuple[bool, str]:
client = connections_service.build_client(row)
try:
user = await client.get_user()
login = user.get("login") or user.get("username") or ""
if login and login != row.get("account_login"):
models.update_connection(row["id"], account_login=login)
return True, f"Connecté : {login}" if login else "Connecté"
except Exception as exc:
return False, f"Échec : {exc}"
finally:
await client.aclose()
@app.post("/admin/connections")
async def admin_create_connection(
request: Request,
kind: str = Form("gitea"),
name: str = Form(""),
base_url: str = Form(""),
token: str = Form(""),
hook_secret: str = Form(""),
csrf_token: str = Form(""),
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
from app import forge_crypto
name = name.strip()
base_url = base_url.strip().rstrip("/")
if not name or not base_url:
return RedirectResponse(
"/admin/connections?err=Nom+et+URL+requis", status_code=303
)
slug = _unique_slug(_slugify(name), "connections")
conn_id = models.create_connection(
slug=slug,
kind="github" if kind == "github" else "gitea",
name=name,
base_url=base_url,
token_encrypted=forge_crypto.encrypt_token(token) if token else "",
webhook_secret=hook_secret,
)
models.log_admin(admin.get("id"), "connection.create", slug)
return RedirectResponse(
f"/admin/connections?msg=Connexion+{slug}+cr%C3%A9%C3%A9e",
status_code=303,
)
@app.post("/admin/connections/{conn_id}")
async def admin_update_connection(
request: Request,
conn_id: int,
name: str = Form(""),
base_url: str = Form(""),
token: str = Form(""),
hook_secret: str = Form(""),
csrf_token: str = Form(""),
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
row = models.get_connection(conn_id)
if row is None:
return RedirectResponse("/admin/connections", status_code=303)
from app import forge_crypto
fields: dict = {}
if name.strip():
fields["name"] = name.strip()
if base_url.strip():
fields["base_url"] = base_url.strip().rstrip("/")
if token:
fields["token_encrypted"] = forge_crypto.encrypt_token(token)
if hook_secret:
fields["webhook_secret"] = hook_secret
if fields:
models.update_connection(conn_id, **fields)
registry.invalidate(conn_id)
models.log_admin(admin.get("id"), "connection.update", row["slug"])
return RedirectResponse(
"/admin/connections?msg=Connexion+mise+%C3%A0+jour", status_code=303
)
@app.post("/admin/connections/{conn_id}/test")
async def admin_test_connection(
request: Request, conn_id: int, csrf_token: str = Form("")
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
row = models.get_connection(conn_id)
if row is None:
return RedirectResponse("/admin/connections", status_code=303)
ok, message = await _test_connection_row(row)
models.log_admin(
admin.get("id"),
"connection.test.ok" if ok else "connection.test.fail",
f"{row['slug']} — {message}",
)
key = "msg" if ok else "err"
from urllib.parse import quote as _q
return RedirectResponse(
f"/admin/connections?{key}={_q(message)}", status_code=303
)
@app.post("/admin/connections/{conn_id}/toggle")
async def admin_toggle_connection(
request: Request, conn_id: int, csrf_token: str = Form("")
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
row = models.get_connection(conn_id)
if row is not None:
models.update_connection(conn_id, enabled=0 if row["enabled"] else 1)
registry.invalidate(conn_id)
models.log_admin(admin.get("id"), "connection.toggle", row["slug"])
return RedirectResponse("/admin/connections", status_code=303)
@app.post("/admin/connections/{conn_id}/delete")
async def admin_delete_connection(
request: Request, conn_id: int, csrf_token: str = Form("")
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
row = models.get_connection(conn_id)
if row is None:
return RedirectResponse("/admin/connections", status_code=303)
used = [
ws["name"]
for ws in models.list_workspaces()
if conn_id in models.workspace_connection_ids(ws["id"])
]
if used:
from urllib.parse import quote as _q
return RedirectResponse(
"/admin/connections?err="
+ _q(f"Utilisée par : {', '.join(used)} — retire-la des espaces d'abord"),
status_code=303,
)
models.delete_connection(conn_id)
registry.invalidate(conn_id)
models.log_admin(admin.get("id"), "connection.delete", row["slug"])
return RedirectResponse(
"/admin/connections?msg=Connexion+supprim%C3%A9e", status_code=303
)
# ── Administration : espaces et paramètres (0.24.0, P3) ───
def _can_admin_workspace(request: Request, workspace_id: int) -> dict | None:
"""Global admin, or admin OF this workspace (issue: matrix §3.3)."""
user = _current_user(request)
if user is None:
return None
if user.get("role") == "admin":
return user
if user.get("id") is not None and (
models.workspace_role(workspace_id, user["id"]) == "admin"
):
return user
return None
def _visible_workspaces_for_admin(request: Request) -> list[dict] | None:
user = _current_user(request)
if user is None:
return None
if user.get("role") == "admin" or user.get("id") is None:
return models.list_workspaces()
return [
ws for ws in models.user_workspaces(user["id"])
if ws.get("member_role") == "admin"
]
@app.get("/admin/workspaces")
async def admin_workspaces(request: Request, msg: str = "", err: str = ""):
visible = _visible_workspaces_for_admin(request)
if visible is None:
return _admin_denied(request)
all_connections = models.list_connections()
details = []
for ws in visible:
details.append(
{
"ws": ws,
"conn_ids": models.workspace_connection_ids(ws["id"]),
"members": models.list_members(ws["id"]),
"selection": models.workspace_project_selection(ws["id"]),
}
)
return render(
request, "admin_workspaces.html",
details=details, all_connections=all_connections,
all_users=models.list_users(),
is_global_admin=(_current_user(request) or {}).get("role") == "admin",
msg=msg, err=err,
)
@app.post("/admin/workspaces")
async def admin_create_workspace(
request: Request,
name: str = Form(""),
color: str = Form("#7c5cfc"),
csrf_token: str = Form(""),
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
name = name.strip()
if not name:
return RedirectResponse(
"/admin/workspaces?err=Nom+requis", status_code=303
)
base = _slugify(name)
slug, n = base, 2
while any(ws["slug"] == slug for ws in models.list_workspaces()):
slug = f"{base}-{n}"
n += 1
ws_id = models.create_workspace(
slug, name, color if color.startswith("#") else "#7c5cfc"
)
if admin.get("id") is not None:
models.set_member(ws_id, admin["id"], "admin")
models.log_admin(admin.get("id"), "workspace.create", slug)
return RedirectResponse(
"/admin/workspaces?msg=Espace+cr%C3%A9%C3%A9", status_code=303
)
@app.post("/admin/workspaces/{ws_id}")
async def admin_update_workspace(
request: Request,
ws_id: int,
name: str = Form(""),
color: str = Form(""),
csrf_token: str = Form(""),
):
admin = _can_admin_workspace(request, ws_id)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
fields: dict = {}
if name.strip():
fields["name"] = name.strip()
if color.startswith("#"):
fields["color"] = color
if fields:
models.update_workspace(ws_id, **fields)
models.log_admin(admin.get("id"), "workspace.update", str(ws_id))
return RedirectResponse("/admin/workspaces", status_code=303)
@app.post("/admin/workspaces/{ws_id}/connections")
async def admin_workspace_connections(
request: Request, ws_id: int, csrf_token: str = Form("")
):
admin = _can_admin_workspace(request, ws_id)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
form = await request.form()
chosen = []
for raw in form.getlist("connection_id"):
try:
chosen.append(int(raw))
except (TypeError, ValueError):
continue
valid = {c["id"] for c in models.list_connections()}
models.set_workspace_connections(
ws_id, [c for c in chosen if c in valid]
)
models.log_admin(
admin.get("id"), "workspace.connections", f"ws={ws_id} -> {chosen}"
)
return RedirectResponse(
"/admin/workspaces?msg=Connexions+mises+%C3%A0+jour", status_code=303
)
@app.post("/admin/workspaces/{ws_id}/members")
async def admin_workspace_member_add(
request: Request,
ws_id: int,
user_id: int = Form(0),
role: str = Form("member"),
csrf_token: str = Form(""),
):
admin = _can_admin_workspace(request, ws_id)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
if models.get_user(user_id) is not None:
models.set_member(
ws_id, user_id,
role if role in ("admin", "member", "reader") else "member",
)
models.log_admin(
admin.get("id"), "workspace.member", f"ws={ws_id} user={user_id}"
)
return RedirectResponse("/admin/workspaces", status_code=303)
@app.post("/admin/workspaces/{ws_id}/members/{user_id}/remove")
async def admin_workspace_member_remove(
request: Request, ws_id: int, user_id: int, csrf_token: str = Form("")
):
admin = _can_admin_workspace(request, ws_id)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
models.remove_member(ws_id, user_id)
models.log_admin(
admin.get("id"), "workspace.member.remove",
f"ws={ws_id} user={user_id}",
)
return RedirectResponse("/admin/workspaces", status_code=303)
@app.post("/admin/workspaces/{ws_id}/projects")
async def admin_workspace_projects(
request: Request,
ws_id: int,
selection: str = Form(""),
csrf_token: str = Form(""),
):
"""Explicit project selection: one "slug:owner/repo" per line.
Empty = every project of the workspace's connections.
"""
admin = _can_admin_workspace(request, ws_id)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
by_slug = {c["slug"]: c["id"] for c in models.list_connections()}
chosen: list[tuple[int, str]] = []
for line in selection.splitlines():
line = line.strip()
if not line:
continue
slug, sep, full_name = line.partition(":")
if sep and slug in by_slug and _split_full_name(full_name):
chosen.append((by_slug[slug], full_name))
elif not sep and _split_full_name(line):
ids = models.workspace_connection_ids(ws_id)
if ids:
chosen.append((ids[0], line))
models.set_workspace_projects(ws_id, chosen)
models.log_admin(
admin.get("id"), "workspace.projects", f"ws={ws_id} n={len(chosen)}"
)
return RedirectResponse(
"/admin/workspaces?msg=S%C3%A9lection+mise+%C3%A0+jour",
status_code=303,
)
@app.post("/admin/workspaces/{ws_id}/delete")
async def admin_workspace_delete(
request: Request, ws_id: int, csrf_token: str = Form("")
):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
if len(models.list_workspaces()) <= 1:
return RedirectResponse(
"/admin/workspaces?err=Dernier+espace+%3A+suppression+interdite",
status_code=303,
)
models.delete_workspace(ws_id)
models.log_admin(admin.get("id"), "workspace.delete", str(ws_id))
return RedirectResponse(
"/admin/workspaces?msg=Espace+supprim%C3%A9", status_code=303
)
@app.get("/admin/settings")
async def admin_settings(request: Request, msg: str = "", err: str = ""):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
from app.config import (
OVERRIDABLE_SETTINGS,
_env_settings,
settings_overrides,
)
env = _env_settings()
overrides = settings_overrides()
effective = get_settings()
rows = []
for key in OVERRIDABLE_SETTINGS:
rows.append(
{
"key": key,
"env_value": getattr(env, key),
"override": overrides.get(key),
"effective": getattr(effective, key),
}
)
return render(
request, "admin_settings.html", rows=rows, msg=msg, err=err
)
@app.post("/admin/settings")
async def admin_settings_save(request: Request, csrf_token: str = Form("")):
admin = _require_admin(request)
if admin is None:
return _admin_denied(request)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
from app.config import OVERRIDABLE_SETTINGS
form = await request.form()
overrides: dict = {}
for key, converter in OVERRIDABLE_SETTINGS.items():
raw = form.get(f"setting_{key}")
if raw is None or str(raw).strip() == "":
continue # champ vide = retour à la valeur d'environnement
try:
overrides[key] = (
str(raw).strip()
if converter is str
else converter(str(raw).strip())
)
except (TypeError, ValueError):
continue
import json as _json
models.set_kv("settings_overrides", _json.dumps(overrides))
models.log_admin(
admin.get("id"), "settings.update", ", ".join(sorted(overrides))
)
return RedirectResponse(
"/admin/settings?msg=Param%C3%A8tres+enregistr%C3%A9s",
status_code=303,
)
def _render_profile(request: Request, user: dict, msg: str = "", err: str = "",
nouveau_jeton: str = ""):
from app import forge_crypto
mine = []
if user.get("id") is not None:
for row in models.list_connections():
if row.get("owner_user_id") == user["id"]:
row = dict(row)
row["token_mask"] = forge_crypto.mask_token(
row["token_encrypted"]
)
mine.append(row)
return render(
request, "profile.html",
profile_user=user,
memberships=(
models.user_workspaces(user["id"])
if user.get("id") is not None
else []
),
my_connections=mine,
api_tokens=(
models.list_api_tokens(user["id"])
if user.get("id") is not None
else []
),
nouveau_jeton=nouveau_jeton,
msg=msg, err=err,
)
@app.get("/profile")
async def profile_page(request: Request, msg: str = "", err: str = ""):
user = _current_user(request)
if user is None:
return RedirectResponse("/login", status_code=303)
return _render_profile(request, user, msg=msg, err=err)
@app.post("/profile/tokens")
async def profile_token_create(
request: Request,
name: str = Form(""),
csrf_token: str = Form(""),
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
nom = name.strip() or "Sans nom"
_row, plaintext = models.create_api_token(user["id"], nom)
models.log_admin(user["id"], "profile.token.create", nom)
# Le texte clair n'est montré qu'ici, une seule fois : la base ne
# conserve que l'empreinte.
return _render_profile(
request, user,
msg="Jeton créé — copie-le maintenant, il ne sera plus jamais affiché.",
nouveau_jeton=plaintext,
)
@app.post("/profile/tokens/{token_id}/revoke")
async def profile_token_revoke(
request: Request,
token_id: int,
csrf_token: str = Form(""),
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
if models.revoke_api_token(user["id"], token_id):
models.log_admin(user["id"], "profile.token.revoke", str(token_id))
return RedirectResponse("/profile?msg=Jeton+r%C3%A9voqu%C3%A9", status_code=303)
return RedirectResponse("/profile?err=Jeton+introuvable", status_code=303)
@app.post("/profile")
async def profile_update(
request: Request,
display_name: str = Form(""),
csrf_token: str = Form(""),
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
models.update_user(user["id"], display_name=display_name.strip())
return RedirectResponse("/profile?msg=Profil+mis+%C3%A0+jour", status_code=303)
@app.post("/profile/password")
async def profile_password(
request: Request,
current_password: str = Form(""),
new_password: str = Form(""),
csrf_token: str = Form(""),
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse(
"/profile?err=Compte+environnement+%3A+mot+de+passe+hors+base",
status_code=303,
)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
from app import accounts as accounts_service
if not accounts_service.verify_password(
current_password, user["password_hash"]
):
return RedirectResponse(
"/profile?err=Mot+de+passe+actuel+incorrect", status_code=303
)
if len(new_password) < 6:
return RedirectResponse(
"/profile?err=Mot+de+passe+trop+court+%28min+6%29", status_code=303
)
models.update_user(
user["id"],
password_hash=accounts_service.hash_password(new_password),
must_change_password=0,
)
models.log_admin(user["id"], "profile.password", user["username"])
return RedirectResponse(
"/profile?msg=Mot+de+passe+modifi%C3%A9", status_code=303
)
@app.post("/profile/draco/unlink")
async def profile_draco_unlink(request: Request, csrf_token: str = Form("")):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
models.update_user(user["id"], draco_sub=None)
models.log_admin(user["id"], "draco.unlink", user["username"])
return RedirectResponse("/profile?msg=Liaison+draco+retir%C3%A9e", status_code=303)
@app.post("/profile/connections")
async def profile_create_connection(
request: Request,
kind: str = Form("gitea"),
name: str = Form(""),
base_url: str = Form(""),
token: str = Form(""),
csrf_token: str = Form(""),
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
from app import forge_crypto
name = name.strip()
base_url = base_url.strip().rstrip("/")
if not name or not base_url:
return RedirectResponse(
"/profile?err=Nom+et+URL+requis", status_code=303
)
slug = _unique_slug(
_slugify(f"{user['username']}-{name}"), "connections"
)
models.create_connection(
slug=slug,
kind="github" if kind == "github" else "gitea",
name=name,
base_url=base_url,
token_encrypted=forge_crypto.encrypt_token(token) if token else "",
owner_user_id=user["id"],
)
models.log_admin(user["id"], "connection.personal.create", slug)
return RedirectResponse(
"/profile?msg=Connexion+personnelle+ajout%C3%A9e", status_code=303
)
@app.post("/profile/connections/{conn_id}/delete")
async def profile_delete_connection(
request: Request, conn_id: int, csrf_token: str = Form("")
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
row = models.get_connection(conn_id)
if row is not None and row.get("owner_user_id") == user["id"]:
models.delete_connection(conn_id)
registry.invalidate(conn_id)
models.log_admin(user["id"], "connection.personal.delete", row["slug"])
return RedirectResponse("/profile?msg=Connexion+supprim%C3%A9e", status_code=303)
@app.post("/profile/connections/{conn_id}/test")
async def profile_test_connection(
request: Request, conn_id: int, csrf_token: str = Form("")
):
user = _current_user(request)
if user is None or user.get("id") is None:
return RedirectResponse("/profile", status_code=303)
if not _csrf_valid(request, csrf_token):
return _csrf_error()
row = models.get_connection(conn_id)
if row is None or row.get("owner_user_id") != user["id"]:
return RedirectResponse("/profile", status_code=303)
ok, message = await _test_connection_row(row)
from urllib.parse import quote as _q
key = "msg" if ok else "err"
return RedirectResponse(f"/profile?{key}={_q(message)}", status_code=303)
# ── Live updates (SSE hub) ─────────────────────────────────
class LiveHub:
"""Fan-out of Gitea webhook events to connected browsers (SSE)."""
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue] = set()
def subscribe(self) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=50)
self._subscribers.add(queue)
return queue
def unsubscribe(self, queue: asyncio.Queue) -> None:
self._subscribers.discard(queue)
async def broadcast(self, payload: dict) -> None:
for queue in list(self._subscribers):
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
pass # slow client: it will resync on its next load
@property
def subscriber_count(self) -> int:
return len(self._subscribers)
hub = LiveHub()
async def _publish_board_changed(repo_full_name: str) -> None:
"""Tell every connected client this repo's board changed (#15).
Called after each mutation Kanbante knows about (move, create,
capture, label toggle). Webhooks broadcast separately with a
richer payload; board clients accept both shapes.
"""
await hub.broadcast({"repo": repo_full_name, "type": "board-changed"})
# ── Templates ──────────────────────────────────────────────
_tpl_env = Environment(
loader=FileSystemLoader(str(BASE / "app" / "templates")),
autoescape=select_autoescape(["html"]),
)
# Which main-nav entry a template represents (issue #16): the
# shared _header.html partial highlights it from this one place
# instead of 19 hand-maintained copies.
_NAV_BY_TEMPLATE = {
"dashboard.html": "home",
"focus.html": "focus",
"today.html": "today",
"my.html": "my",
"activity.html": "activity",
"briefing.html": "briefing",
"standup.html": "standup",
"search.html": "search",
"capture.html": "capture",
"digest.html": "digest",
"forecast.html": "forecast",
"rules.html": "rules",
"health.html": "health",
"health_project.html": "health",
"admin.html": "admin",
"admin_connections.html": "admin_connections",
"admin_workspaces.html": "admin_workspaces",
"admin_settings.html": "admin_settings",
"archives.html": "archives",
}
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)
# Fusion ACC-1 : initiale de l'opérateur pour la barre d'apps commune.
context.setdefault(
"draco_avatar", (get_settings().auth_user or "K")[:1].upper()
)
context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, ""))
context.setdefault("auth_enabled", get_settings().auth_enabled)
context.setdefault("data_freshness", snapshots_service.freshness_label())
# 0.24.0 (P2/P3) : utilisateur courant, espaces, sélecteur.
try:
_cu = _current_user(request)
except Exception:
_cu = None
context.setdefault("current_user", _cu)
context.setdefault("is_admin", bool(_cu and _cu.get("role") == "admin"))
try:
_ws, _ws_role = _current_workspace(request)
context.setdefault("current_workspace", _ws)
context.setdefault("workspace_role", _ws_role)
context.setdefault(
"user_workspaces",
models.user_workspaces(_cu["id"])
if _cu and _cu.get("id") is not None
else models.list_workspaces(),
)
except Exception:
context.setdefault("current_workspace", None)
context.setdefault("user_workspaces", [])
html = _tpl_env.get_template(name).render(**context)
response = HTMLResponse(html, status_code=status_code)
if generated:
response.set_cookie(CSRF_COOKIE, token, samesite="lax", httponly=True)
return response
def _csrf_valid(request: Request, form_token: str) -> bool:
cookie = request.cookies.get(CSRF_COOKIE, "")
provided = form_token or request.headers.get("x-csrf-token", "")
return bool(cookie) and bool(provided) and secrets.compare_digest(cookie, provided)
def _csrf_error() -> JSONResponse:
return JSONResponse(
{"ok": False, "error": "Invalid or missing CSRF token"}, status_code=403
)
@app.exception_handler(httpx.HTTPError)
async def gitea_error_handler(request: Request, exc: httpx.HTTPError):
"""Gitea failures become a clean 502, never a bare 500."""
logger.warning("Gitea error on %s: %s", request.url.path, exc)
if request.url.path.startswith("/api/"):
return JSONResponse(
{"ok": False, "error": "Gitea is unreachable or returned an error"},
status_code=502,
)
return render(
request,
"error.html",
status_code=502,
message="Kanbante n'a pas pu joindre Gitea. Vérifie que l'instance "
"est en ligne et que le jeton est valide, puis réessaie.",
)
# ── Helpers ────────────────────────────────────────────────
def _parse_datetime(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
async def _parallel_map(items, fn, limit: int = 16) -> list:
"""Run an async fn over items with bounded concurrency (0.19.0).
Cross-project pages used to await each repo in sequence, so the
page took the SUM of every Gitea round trip. Gathering with a
per-call semaphore (created here, bound to the current loop)
cuts that to roughly the slowest single repo, without letting
a big instance stampede Gitea. Exceptions come back in place —
callers decide what a failed repo means for them.
"""
semaphore = asyncio.Semaphore(limit)
async def _run(item):
async with semaphore:
return await fn(item)
return await asyncio.gather(
*(_run(item) for item in items), return_exceptions=True
)
def classify_repos(
repos: list[dict], now: datetime | None = None
) -> tuple[list[dict], list[dict], list[dict]]:
"""Split repos into (active, inactive, archived).
"Archived" is Gitea's own flag. Among the rest, a repo is active if
it was updated within the configured rolling window — no hardcoded
calendar year.
"""
settings = get_settings()
now = now or datetime.now(timezone.utc)
cutoff = now - timedelta(days=settings.active_window_days)
active, inactive, archived = [], [], []
for repo in repos:
if repo.get("archived"):
archived.append(repo)
continue
updated = _parse_datetime(repo.get("updated_at"))
if updated is not None and updated >= cutoff:
active.append(repo)
else:
inactive.append(repo)
return active, inactive, archived
def _is_issue(item: dict) -> bool:
"""True for issues, False for pull requests.
Gitea's issues endpoint also returns PRs. A PR carries a
``pull_request`` *object*; a plain issue carries the same key
with value ``null`` (verified against Gitea 1.27.3). Testing key
presence therefore classifies every real issue as a PR and
empties the board — test the value's truthiness instead.
"""
return not item.get("pull_request")
def _merge_issues_with_board(
issues: list[dict], board: dict[str, list[dict]]
) -> dict[str, list[dict]]:
"""Merge Gitea issues into kanban columns, honouring saved order."""
placement: dict[int, tuple[str, float]] = {}
for col_name, cards in board.items():
for card in cards:
placement[card["issue_number"]] = (col_name, card["position"])
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
for index, issue in enumerate(issues):
col, pos = placement.get(issue["number"], ("backlog", None))
issue["_sort"] = (0, pos, index) if pos is not None else (1, 0.0, index)
columns[col].append(issue)
for col in columns.values():
col.sort(key=lambda i: i["_sort"])
return columns
def _adopt_label_columns(
full_name: str,
open_issues: list[dict],
board_state: dict,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
"""Adopt the column named by an issue's kanban:* label, if any.
Labels are the Gitea-visible mirror of columns (written on every
drag). If the label changed outside Kanbante — edited in Gitea,
or set by another tool — the board follows it and the move is
journaled (source "gitea"). Rules: exactly one kanban:* label
(zero or several = no opinion), target must be a labelled column,
and label synchronisation must be enabled.
"""
if not get_settings().sync_labels:
return
placement: dict[int, str] = {}
for col_name, cards in board_state.items():
for card in cards:
placement[card["issue_number"]] = col_name
for issue in open_issues:
names = [
str(label.get("name", "")) for label in issue.get("labels") or []
]
kanban = [n for n in names if n.startswith("kanban:")]
if len(kanban) != 1:
continue
target = kanban[0].split(":", 1)[1]
if target not in LABELLED_COLUMNS:
continue
current = placement.get(issue["number"], "backlog")
if current != target:
move_issue(
full_name, issue["number"], target,
workspace_id=workspace_id, connection_id=connection_id,
)
log_event(
full_name, issue["number"], "moved",
from_column=current, to_column=target, source="gitea",
workspace_id=workspace_id, connection_id=connection_id,
)
placement[issue["number"]] = target
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
# ── Portée forge/espace des boards (0.24.0, P3) ───────────
def _forge_client(connection_id: int):
"""Live client for a connection (the module-global client
when the registry has nothing — tests, pre-bootstrap)."""
client = registry._clients.get(connection_id)
if client is not None:
return client
built = registry.client_for(connection_id)
return built if built is not None else gitea
async def _resolve_board_scope(request: Request, owner: str, repo: str):
"""(client, connection_id, workspace_id) for one repo.
Inside the current workspace, the first connection whose
repo index contains the repo wins; a single-connection
workspace (the historical case) resolves without any extra
call. Falls back to the legacy client and default scope.
"""
workspace, _role = _current_workspace(request)
ws_id = workspace["id"] if workspace else 1
full_name = f"{owner}/{repo}"
if workspace:
conn_ids = models.workspace_connection_ids(ws_id)
if len(conn_ids) == 1:
cid = conn_ids[0]
return _forge_client(cid), cid, ws_id
for cid in conn_ids:
client = _forge_client(cid)
try:
names = {
r.get("full_name") for r in await client.search_repos()
}
except Exception:
continue
if full_name in names:
return client, cid, ws_id
return gitea, registry.default_id, ws_id
async def _workspace_repos(
request: Request, include_local_archived: bool = False
):
"""(repos annotés, workspace) : projets de l'espace courant.
Chaque dépôt est annoté _conn_id/_conn_slug/_conn_kind ;
la sélection explicite de l'espace filtre si elle existe ;
les archives LOCALES de l'espace sont exclues (page
/archives pour les retrouver) sauf demande explicite.
"""
workspace, _role = _current_workspace(request)
if workspace is None:
return [], None
ws_id = workspace["id"]
selection = set(models.workspace_project_selection(ws_id))
locally_archived = {
(r["connection_id"], r["repo_full_name"])
for r in models.list_local_archived(ws_id)
}
repos: list[dict] = []
conn_ids = models.workspace_connection_ids(ws_id)
for cid in conn_ids:
client = _forge_client(cid)
row = models.get_connection(cid)
try:
found = await client.search_repos()
except Exception as exc:
if len(conn_ids) == 1:
# Single-connection workspace: a forge outage
# stays a clean 502 (historical contract) instead
# of an empty page pretending all is well.
raise
logger.debug("workspace repos failed for conn %s: %s", cid, exc)
continue
for repo in found:
repo = dict(repo)
repo["_conn_id"] = cid
repo["_conn_slug"] = row["slug"] if row else ""
repo["_conn_kind"] = row["kind"] if row else "gitea"
if selection and (cid, repo["full_name"]) not in selection:
continue
if (
not include_local_archived
and (cid, repo["full_name"]) in locally_archived
):
continue
repos.append(repo)
return repos, workspace
# ── Routes: Pages ─────────────────────────────────────────
async def _next_milestone(
owner: str, repo: str, client=None
) -> dict | None:
"""Next open milestone for a project card (issue #17).
The dated milestone with the nearest deadline wins (undated
ones last). Progress and counts come from Gitea's own milestone
counters; the ETA reuses the repo's journal throughput, exactly
like the board banner — one definition of "late", everywhere.
"""
client = client or gitea
try:
milestones = await client.get_milestones(owner, repo, state="open")
except Exception as exc:
logger.debug("next milestone failed for %s/%s: %s", owner, repo, exc)
return None
open_ms = [m for m in milestones if m.get("state", "open") == "open"]
if not open_ms:
return None
def _key(m: dict):
due = _parse_datetime(str(m.get("due_on") or ""))
return (0, due) if due is not None else (1, None)
open_ms.sort(key=_key)
m = open_ms[0]
opened = m.get("open_issues") or 0
closed = m.get("closed_issues") or 0
total = opened + closed
due_dt = _parse_datetime(str(m.get("due_on") or ""))
due_date = due_dt.date() if due_dt else None
today = datetime.now(timezone.utc).date()
weekly = forecast_service.weekly_closures(
get_events(repo_full_name=f"{owner}/{repo}", limit=2000)
)
est = forecast_service.estimate(opened, weekly)
return {
"title": m.get("title", ""),
"total": total,
"closed": closed,
"pct": round(100 * closed / total) if total else 0,
"due_label": (
forecast_service.french_date(due_date, short=True) if due_date else None
),
"late": bool(due_date and due_date < today and opened > 0),
"eta_label": forecast_service.eta_label(est) if est else None,
"eta_late": bool(
est and due_date and not est.get("done") and est["central"] > due_date
),
}
def _health_dot(health: dict) -> dict:
"""Traffic-light summary of a project's health (issue #18).
Rules (single definition, shared with /health): red when
zombies pile up (≥3, or ≥2 stuck in QA/Review); orange on any
zombie or when the slowest column's median dwell reaches
twice the aging threshold; green otherwise.
"""
zombies = health.get("zombies") or []
stuck = [z for z in zombies if z.get("column") in ("qa", "review")]
slowest = health.get("slowest")
median = slowest.get("median_days") if slowest else None
aging = get_settings().aging_days
if len(zombies) >= 3 or len(stuck) >= 2:
level = "red"
elif zombies or (median is not None and median >= 2 * aging):
level = "orange"
else:
level = "green"
parts = []
if zombies:
parts.append(f"{len(zombies)} zombie(s)")
if slowest:
parts.append(
f"goulot : {COLUMN_LABELS[slowest['column']]}"
f" (médiane {median} j)"
)
return {
"level": level,
"tooltip": " · ".join(parts) if parts
else "Aucun signal — voir la santé du projet",
}
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""Main dashboard — all projects at a glance.
Since 0.24.0 the projects are those of the CURRENT
WORKSPACE (its connections, its selection, its local
archives excluded); each card carries its connection badge.
"""
repos, workspace = await _workspace_repos(request)
active, inactive, archived = classify_repos(repos)
ws_id = workspace["id"] if workspace else 1
starred_keys: set[tuple[int, str]] = set()
if workspace:
for cid in models.workspace_connection_ids(ws_id):
try:
client = _forge_client(cid)
for r in await client.list_starred():
starred_keys.add((cid, r.get("full_name")))
except Exception as exc: # stars are decoration (#20)
logger.debug("starred list failed (conn %s): %s", cid, exc)
async def _enrich_active(repo_info: dict) -> None:
full_name = repo_info["full_name"]
owner, _, repo = full_name.partition("/")
cid = repo_info.get("_conn_id", registry.default_id)
client = _forge_client(cid)
repo_info["forecast_label"] = _repo_forecast_label(
full_name, repo_info.get("open_issues_count") or 0
)
repo_info["next_milestone"] = await _next_milestone(
owner, repo, client
)
try:
health = await _project_health(
owner, repo, client,
workspace_id=ws_id, connection_id=cid,
)
repo_info["health_dot"] = _health_dot(health)
except Exception as exc:
logger.debug("health dot failed for %s: %s", full_name, exc)
repo_info["health_dot"] = None
repo_info["starred"] = (cid, full_name) in starred_keys
await _parallel_map(active, _enrich_active)
for repo_info in inactive:
repo_info["starred"] = (
repo_info.get("_conn_id", registry.default_id),
repo_info["full_name"],
) in starred_keys
activity_by_repo = get_project_activity()
today_utc = datetime.now(timezone.utc).date()
for repo_info in active + inactive + archived:
act = activity_by_repo.get(repo_info["full_name"])
if act is None:
repo_info["activity"] = None
continue
idle_days = None
if act["last_at"]:
last_dt = _parse_datetime(act["last_at"])
if last_dt is not None:
idle_days = (today_utc - last_dt.date()).days
repo_info["activity"] = {
"closed": act["closed"],
"moved": act["moved"],
"idle_days": idle_days,
"last_at": act["last_at"] or "",
}
for repo_info in active:
repo_info["home_tab"] = "active"
for repo_info in inactive:
repo_info["home_tab"] = "inactive"
for repo_info in archived:
repo_info["home_tab"] = "archived"
pinned = [r for r in active + inactive if r.get("starred")]
return render(
request,
"dashboard.html",
active_repos=active,
inactive_repos=inactive,
archived_repos=archived,
active_listed=[r for r in active if not r.get("starred")],
inactive_listed=[r for r in inactive if not r.get("starred")],
pinned_repos=pinned,
stats=get_stats(),
COLUMN_LABELS=COLUMN_LABELS,
COLUMN_COLORS=COLUMN_COLORS,
active_window_days=get_settings().active_window_days,
metrics=get_metrics(window_days=30),
multi_conn=len(models.workspace_connection_ids(ws_id)) > 1,
)
@app.post("/api/projects/{owner}/{repo}/star")
async def api_project_star(
request: Request,
owner: str,
repo: str,
starred: str = Form(...),
csrf_token: str = Form(""),
):
"""Pin / unpin a project via its forge star (issue #20)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
want = starred == "1"
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
await client.set_starred(owner, repo, want)
return JSONResponse({"ok": True, "starred": want})
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str, milestone: str = ""):
"""Kanban board for a specific repo (optionally one milestone)."""
full_name = f"{owner}/{repo}"
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
all_items = await client.get_repo_issues(owner, repo, state="all")
issues = [i for i in all_items if _is_issue(i)]
open_issues = [i for i in issues if i.get("state") == "open"]
closed_numbers = {i["number"] for i in issues if i.get("state") != "open"}
open_numbers = {i["number"] for i in open_issues}
# Keep local state truthful: closed in the forge -> done,
# deleted -> purged (in THIS workspace/connection scope).
changes = reconcile_board_state(
full_name, open_numbers, closed_numbers,
workspace_id=ws_id, connection_id=conn_id,
)
for number in changes["closed_numbers"]:
log_event(
full_name, number, "closed", to_column="done", source="gitea",
workspace_id=ws_id, connection_id=conn_id,
)
# A kanban:* label changed outside Kanbante wins over local state.
_adopt_label_columns(
full_name, open_issues,
get_board_state(
full_name, workspace_id=ws_id, connection_id=conn_id
),
workspace_id=ws_id, connection_id=conn_id,
)
# Closed issues stay visible while the board tracks them as done
# (reconcile_board_state moved their row). Merging open issues only
# made the "Terminé" column permanently empty.
done_numbers = {
c["issue_number"]
for c in get_board_state(
full_name, workspace_id=ws_id, connection_id=conn_id
).get("done", [])
}
closed_display = [
i
for i in issues
if i.get("state") != "open" and i["number"] in done_numbers
]
# Milestone view: filter the board to one milestone (sprint-style),
# with its deadline and completion progress.
milestones: list[dict] = []
selected_milestone = None
milestone_progress = None
milestone_forecast = None
display_issues = open_issues + closed_display
try:
milestones = await client.get_milestones(owner, repo)
except Exception as exc: # milestones are decoration, never fatal
logger.debug("milestones failed for %s: %s", full_name, exc)
milestones = []
if milestone:
def _in_milestone(issue: dict) -> bool:
ms = issue.get("milestone") or {}
return bool(ms) and (
str(ms.get("id")) == milestone or ms.get("title") == milestone
)
display_issues = [i for i in display_issues if _in_milestone(i)]
selected_milestone = next(
(
m for m in milestones
if str(m.get("id")) == milestone or m.get("title") == milestone
),
None,
)
if selected_milestone is None and display_issues:
selected_milestone = display_issues[0].get("milestone")
scoped = [i for i in issues if _in_milestone(i)]
total = len(scoped)
closed_count = sum(1 for i in scoped if i.get("state") == "closed")
milestone_progress = {
"total": total,
"closed": closed_count,
"pct": round(100 * closed_count / total) if total else 0,
"due_on": (selected_milestone or {}).get("due_on"),
"title": (selected_milestone or {}).get("title", milestone),
}
# Forecast (issue #7): ETA of the remaining milestone work
# at this repo's real closure pace.
remaining = total - closed_count
weekly = forecast_service.weekly_closures(
get_events(repo_full_name=full_name, limit=2000)
)
est = forecast_service.estimate(remaining, weekly)
if est is None:
milestone_forecast = "pas assez d'historique pour prévoir"
else:
milestone_forecast = forecast_service.eta_label(est)
due_raw = milestone_progress["due_on"]
if due_raw and not est.get("done"):
due_date = _parse_datetime(str(due_raw))
if due_date and est["central"] > due_date.date():
milestone_forecast += " — ⚠️ après l'échéance"
board_state = get_board_state(
full_name, workspace_id=ws_id, connection_id=conn_id
)
columns = _merge_issues_with_board(display_issues, board_state)
await _annotate_code(owner, repo, columns, client)
labels: dict[str, dict] = {}
for issue in open_issues:
for label in issue.get("labels") or []:
labels[label["name"]] = label
repo_info = {}
try:
repo_info = await client.get_repo(owner, repo)
except Exception as exc: # non-critical decoration
logger.debug("repo info failed for %s: %s", full_name, exc)
settings = get_settings()
return render(
request,
"board.html",
owner=owner,
repo=repo,
full_name=full_name,
columns=columns,
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
labels=sorted(labels.values(), key=lambda l: l["name"].lower()),
notes=get_notes(
full_name, workspace_id=ws_id, connection_id=conn_id
) or "",
repo_info=repo_info,
ages=_card_ages(
full_name, display_issues, board_state,
workspace_id=ws_id, connection_id=conn_id,
),
wip_limits=settings.wip_limits or {},
aging_days=settings.aging_days,
milestones=milestones,
selected_milestone=selected_milestone,
milestone_progress=milestone_progress,
milestone_forecast=milestone_forecast,
milestone_param=milestone,
)
def _board_columns_for(
owner: str,
repo: str,
all_items: list[dict],
milestone: str = "",
workspace_id: int = 1,
connection_id: int = 1,
) -> tuple[dict[str, list[dict]], list[dict], dict]:
"""Reconcile + merge pipeline shared by the board page and its
JSON twin (issue #15). Returns (columns, display_issues, state)."""
full_name = f"{owner}/{repo}"
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}
changes = reconcile_board_state(
full_name, open_numbers, closed_numbers,
workspace_id=workspace_id, connection_id=connection_id,
)
for number in changes["closed_numbers"]:
log_event(
full_name, number, "closed", to_column="done", source="gitea",
workspace_id=workspace_id, connection_id=connection_id,
)
_adopt_label_columns(
full_name, open_issues,
get_board_state(
full_name, workspace_id=workspace_id, connection_id=connection_id
),
workspace_id=workspace_id, connection_id=connection_id,
)
board_state = get_board_state(
full_name, workspace_id=workspace_id, connection_id=connection_id
)
# Closed issues stay visible while the board tracks them as done
# (reconcile_board_state moved their row). Merging open issues only
# made the "Terminé" column permanently empty.
done_numbers = {c["issue_number"] for c in board_state.get("done", [])}
display_issues = open_issues + [
i
for i in issues
if i.get("state") != "open" and i["number"] in done_numbers
]
if milestone:
def _in_ms(issue: dict) -> bool:
ms = issue.get("milestone") or {}
return bool(ms) and (
str(ms.get("id")) == milestone or ms.get("title") == milestone
)
display_issues = [i for i in display_issues if _in_ms(i)]
columns = _merge_issues_with_board(display_issues, board_state)
return columns, display_issues, board_state
@app.get("/api/board/{owner}/{repo}")
async def api_board(
request: Request, owner: str, repo: str, milestone: str = ""
):
"""The board as JSON + server-rendered card fragments (issue #15).
The board page swaps these fragments in place when the board
changes elsewhere — same merge/reconcile pipeline as the HTML
page, so label adoptions and closures are reflected identically.
"""
full_name = f"{owner}/{repo}"
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
all_items = await client.get_repo_issues(owner, repo, state="all")
columns, display_issues, board_state = _board_columns_for(
owner, repo, all_items, milestone,
workspace_id=ws_id, connection_id=conn_id,
)
await _annotate_code(owner, repo, columns, client)
settings = get_settings()
ages = _card_ages(
full_name, display_issues, board_state,
workspace_id=ws_id, connection_id=conn_id,
)
card_template = _tpl_env.get_template("_card.html")
payload: dict[str, dict] = {}
for col in KANBAN_COLUMNS:
cards = columns[col]
if cards:
html = "".join(
card_template.render(
issue=issue, ages=ages,
aging_days=settings.aging_days, full_name=full_name,
)
for issue in cards
)
else:
html = '<p class="column-empty">Glissez une carte ici</p>'
payload[col] = {"count": len(cards), "html": html}
return {"repo": full_name, "columns": payload}
def _card_ages(
full_name: str,
open_issues: list[dict],
board: dict[str, list[dict]],
workspace_id: int = 1,
connection_id: int = 1,
) -> dict[int, dict]:
"""Days each open card has spent in its current column.
Source of truth, in order: the event journal (latest entry),
board_state.updated_at (pre-journal data), the issue's own
creation date.
"""
entered = column_entry_times(
full_name, workspace_id=workspace_id, connection_id=connection_id
)
updated = {
card["issue_number"]: card["updated_at"]
for cards in board.values()
for card in cards
}
now = datetime.now(timezone.utc)
ages: dict[int, dict] = {}
for issue in open_issues:
number = issue["number"]
raw = entered.get(number) or updated.get(number) or issue.get("created_at")
since = _parse_datetime(raw)
if since is None:
continue
if since.tzinfo is None:
since = since.replace(tzinfo=timezone.utc)
ages[number] = {"days": max(0, (now - since).days), "since": raw}
return ages
async def _collect_focus_groups(
repos: list[dict] | None = None, workspace_id: int = 1
) -> list[dict]:
"""In-progress / in-review cards across all active repos.
`repos` = annotated workspace repos (0.24.0); None keeps the
historical behaviour (default connection, default scope).
"""
if repos is None:
repos = [
{**r, "_conn_id": registry.default_id}
for r in 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("/")
client = _forge_client(repo.get("_conn_id", registry.default_id))
items = await client.get_repo_issues(owner, name, state="open")
return repo, [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
repo, open_issues = result
full_name = repo["full_name"]
cid = repo.get("_conn_id", registry.default_id)
state = get_board_state(
full_name, workspace_id=workspace_id, connection_id=cid
)
_adopt_label_columns(
full_name, open_issues, state,
workspace_id=workspace_id, connection_id=cid,
)
columns = _merge_issues_with_board(
open_issues,
get_board_state(
full_name, workspace_id=workspace_id, connection_id=cid
),
)
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, "conn_id": cid}
)
return groups
@app.post("/api/issues/{owner}/{repo}/{number}/assign-self")
async def api_assign_self(
request: Request,
owner: str,
repo: str,
number: int,
csrf_token: str = Form(""),
):
"""Assign the token's user to an issue (triage key A, issue #11)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
user = await client.get_user()
login = user.get("login", "")
if not login:
return JSONResponse(
{"ok": False, "error": "Could not resolve the current user"},
status_code=502,
)
issue = await client.get_issue(owner, repo, number)
current = [
a.get("login", "")
for a in (issue.get("assignees") or [])
if a.get("login")
]
if issue.get("assignee") and issue["assignee"].get("login"):
if issue["assignee"]["login"] not in current:
current.append(issue["assignee"]["login"])
if login not in current:
current.append(login)
await client.set_issue_assignees(owner, repo, number, current)
await _publish_board_changed(f"{owner}/{repo}")
return {"ok": True, "assignees": current}
def _excerpt(body: str | None, limit: int = 280) -> str:
"""Plain-text excerpt of an issue body for the triage card."""
text = re.sub(r"[#>*`_~\-]{1,}", " ", body or "")
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"\s+", " ", text).strip()
return text[:limit] + ("…" if len(text) > limit else "")
@app.get("/project/{owner}/{repo}", response_class=HTMLResponse)
async def project_page(request: Request, owner: str, repo: str):
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
"""Project home (issue #25): milestones, health summary,
forecast, recent journal activity and notes for one repo —
an assembly of existing bricks, one truth per block."""
full_name = f"{owner}/{repo}"
repo_info = await client.get_repo(owner, repo)
try:
raw_milestones = await client.get_milestones(owner, repo, state="open")
except Exception as exc:
logger.debug("project milestones failed for %s: %s", full_name, exc)
raw_milestones = []
weekly = forecast_service.weekly_closures(
get_events(repo_full_name=full_name, limit=2000)
)
throughput = forecast_service.throughput_stats(weekly)
today = datetime.now(timezone.utc).date()
milestones = []
for m in raw_milestones:
if m.get("state", "open") != "open":
continue
opened = m.get("open_issues") or 0
closed = m.get("closed_issues") or 0
total = opened + closed
due_dt = _parse_datetime(str(m.get("due_on") or ""))
due_date = due_dt.date() if due_dt else None
est = forecast_service.estimate(opened, weekly)
milestones.append(
{
"title": m.get("title", ""),
"total": total,
"closed": closed,
"open": opened,
"pct": round(100 * closed / total) if total else 0,
"due_label": (
forecast_service.french_date(due_date, short=True)
if due_date else None
),
"late": bool(due_date and due_date < today and opened > 0),
"eta_label": forecast_service.eta_label(est) if est else None,
}
)
try:
health = await _project_health(owner, repo)
health_dot = _health_dot(health)
except Exception as exc:
logger.debug("project health failed for %s: %s", full_name, exc)
health, health_dot = None, None
events = [
e for e in get_events(limit=300)
if e["repo_full_name"] == full_name
][:10]
return render(
request,
"project.html",
repo_info=repo_info,
full_name=full_name,
owner=owner,
repo=repo,
milestones=milestones,
throughput=throughput,
health=health,
health_dot=health_dot,
events=events,
notes=get_notes(full_name, workspace_id=ws_id, connection_id=conn_id),
EVENT_ICONS=EVENT_ICONS,
COLUMN_LABELS=COLUMN_LABELS,
COLUMN_COLORS=COLUMN_COLORS,
)
@app.get("/triage", response_class=HTMLResponse)
async def triage_picker(request: Request):
"""Triage mode (issue #11): pick a project by its backlog size."""
workspace, _role = _current_workspace(request)
ws_id = workspace["id"] if workspace else 1
repos, _ws = await _workspace_repos(request)
active, _inactive, _archived = classify_repos(repos)
async def _one_triage(repo_info: dict) -> dict | None:
owner, _, name = repo_info["full_name"].partition("/")
cid = repo_info.get("_conn_id", registry.default_id)
client = _forge_client(cid)
all_items = await client.get_repo_issues(owner, name, state="all")
columns, _display, _state = _board_columns_for(
owner, name, all_items,
workspace_id=ws_id, connection_id=cid,
)
backlog = len(columns["backlog"])
if backlog:
return {"full_name": repo_info["full_name"], "backlog": backlog}
return None
results = await _parallel_map(active, _one_triage)
projects = [r for r in results if isinstance(r, dict)]
projects.sort(key=lambda p: -p["backlog"])
return render(request, "triage.html", projects=projects)
@app.get("/triage/{owner}/{repo}", response_class=HTMLResponse)
async def triage_session(request: Request, owner: str, repo: str):
"""One backlog card at a time, decided from the keyboard."""
full_name = f"{owner}/{repo}"
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
all_items = await client.get_repo_issues(owner, repo, state="all")
columns, display_issues, board_state = _board_columns_for(
owner, repo, all_items,
workspace_id=ws_id, connection_id=conn_id,
)
ages = _card_ages(
full_name, display_issues, board_state,
workspace_id=ws_id, connection_id=conn_id,
)
queue = [
{
"number": issue["number"],
"title": issue.get("title", ""),
"excerpt": _excerpt(issue.get("body")),
"labels": [
{"name": l.get("name", ""), "color": l.get("color", "")}
for l in issue.get("labels") or []
],
"created_at": (issue.get("created_at") or "")[:10],
"age_days": (ages.get(issue["number"]) or {}).get("days"),
"html_url": issue.get("html_url", ""),
"assignees": [
a.get("login", "") for a in issue.get("assignees") or []
],
}
for issue in columns["backlog"]
]
return render(
request,
"triage_session.html",
full_name=full_name,
queue=queue,
queue_json=json.dumps(queue, ensure_ascii=False),
column_labels=COLUMN_LABELS,
)
def _today_str(offset_days: int = 0) -> str:
return (datetime.now() + timedelta(days=offset_days)).strftime("%Y-%m-%d")
async def _enrich_pick(
pick: dict, workspace_id: int = 1
) -> dict | None:
"""A today pick + its live issue data and current column."""
owner, _, name = pick["repo_full_name"].partition("/")
cid = pick.get("connection_id") or registry.default_id
client = _forge_client(cid)
try:
issue = await client.get_issue(owner, name, pick["issue_number"])
except Exception:
return None
if issue.get("state") == "closed":
column = "done"
else:
placement = {
card["issue_number"]: col
for col, cards in get_board_state(
pick["repo_full_name"],
workspace_id=workspace_id, connection_id=cid,
).items()
for card in cards
}
column = placement.get(pick["issue_number"], "backlog")
return {
"repo": pick["repo_full_name"],
"number": pick["issue_number"],
"title": issue.get("title", ""),
"state": issue.get("state", "open"),
"column": column,
"column_label": COLUMN_LABELS[column],
"html_url": issue.get("html_url", ""),
}
@app.post("/api/today/pin")
async def api_today_pin(
request: Request,
repo_full_name: str = Form(...),
issue_number: int = Form(...),
csrf_token: str = Form(""),
):
"""Pin a card in today's focus (max 3, issue #5)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
if _split_full_name(repo_full_name) is None:
return JSONResponse(
{"ok": False, "error": "Invalid repo_full_name"}, status_code=400
)
today = _today_str()
parts = _split_full_name(repo_full_name)
_client, conn_id, ws_id = await _resolve_board_scope(
request, parts[0], parts[1]
)
picks = get_picks(today, workspace_id=ws_id)
mine = [
p for p in picks
if p["repo_full_name"] == repo_full_name
and p["issue_number"] == issue_number
and p.get("connection_id") == conn_id
]
if mine:
return {"ok": True, "already": True, "count": len(picks)}
if len(picks) >= 3:
return JSONResponse(
{
"ok": False,
"error": "Focus du jour complet (3 cartes max) — "
"retire d'abord une carte sur /today.",
},
status_code=400,
)
add_pick(
today, repo_full_name, issue_number,
workspace_id=ws_id, connection_id=conn_id,
)
return {"ok": True, "count": len(picks) + 1}
@app.post("/api/today/unpin")
async def api_today_unpin(
request: Request,
repo_full_name: str = Form(...),
issue_number: int = Form(...),
csrf_token: str = Form(""),
):
"""Remove a card from today's focus."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
parts = _split_full_name(repo_full_name)
if parts is not None:
_client, conn_id, ws_id = await _resolve_board_scope(
request, parts[0], parts[1]
)
remove_pick(
_today_str(), repo_full_name, issue_number,
workspace_id=ws_id, connection_id=conn_id,
)
else:
remove_pick(_today_str(), repo_full_name, issue_number)
return {"ok": True}
@app.get("/today", response_class=HTMLResponse)
async def today_page(request: Request):
"""Focus of the day (issue #5): up to 3 pinned cards, calmly."""
workspace, _role = _current_workspace(request)
ws_id = workspace["id"] if workspace else 1
today = _today_str()
picks = []
for pick in get_picks(today, workspace_id=ws_id):
enriched = await _enrich_pick(pick, workspace_id=ws_id)
if enriched:
picks.append(enriched)
picked_keys = {(p["repo"], p["number"]) for p in picks}
carry_over = []
for pick in get_picks(_today_str(-1), workspace_id=ws_id):
if (pick["repo_full_name"], pick["issue_number"]) in picked_keys:
continue
enriched = await _enrich_pick(pick, workspace_id=ws_id)
if enriched and enriched["state"] != "closed":
carry_over.append(enriched)
candidates = []
if len(picks) < 3:
ws_repos, _ws = await _workspace_repos(request)
for group in await _collect_focus_groups(ws_repos, ws_id):
for card in group["cards"]:
key = (group["full_name"], card["issue"]["number"])
if key in picked_keys:
continue
candidates.append(
{
"repo": group["full_name"],
"number": card["issue"]["number"],
"title": card["issue"].get("title", ""),
"column_label": COLUMN_LABELS[card["column"]],
}
)
candidates = candidates[:8]
recap = []
picked_numbers = {(p["repo"], p["number"]) for p in picks}
for event in get_events(limit=300):
if not str(event.get("created_at") or "").startswith(today):
continue
if (event["repo_full_name"], event["issue_number"]) in picked_numbers:
recap.append(event)
return render(
request,
"today.html",
date_label=datetime.now().strftime("%A %d %B %Y"),
picks=picks,
carry_over=carry_over,
candidates=candidates,
recap=recap,
column_labels=COLUMN_LABELS,
)
async def _sprint_markdown(
owner: str, repo: str, milestone: str, client=None
):
"""Assemble the sprint story; (title, markdown) or None."""
full_name = f"{owner}/{repo}"
client = client or gitea
try:
milestones = await client.get_milestones(owner, repo)
except Exception:
milestones = []
selected = next(
(
m for m in milestones
if str(m.get("id")) == milestone or m.get("title") == milestone
),
None,
)
if selected is None:
return None
def _in_ms(issue: dict) -> bool:
ms = issue.get("milestone") or {}
return bool(ms) and (
str(ms.get("id")) == str(selected.get("id"))
or ms.get("title") == selected.get("title")
)
all_items = await client.get_repo_issues(owner, repo, state="all")
scoped = [i for i in all_items if _is_issue(i) and _in_ms(i)]
numbers = {i["number"] for i in scoped}
events = [
e for e in get_events(repo_full_name=full_name, limit=2000)
if e["issue_number"] in numbers
]
first_in_progress: dict[int, object] = {}
closed_at: dict[int, object] = {}
counts = {"moved": 0, "commented": 0, "reopened": 0, "created": 0}
event_dates = []
for event in events:
at = _parse_datetime(str(event.get("created_at") or ""))
if at is not None:
event_dates.append(at)
if event["event_type"] in counts:
counts[event["event_type"]] += 1
if (
event["event_type"] == "moved"
and event.get("to_column") == "in_progress"
):
first_in_progress.setdefault(event["issue_number"], at)
if event["event_type"] == "closed":
closed_at[event["issue_number"]] = at
columns, _display, _state = _board_columns_for(owner, repo, all_items)
placement = {
card["number"]: col for col, cards in columns.items() for card in cards
}
delivered, carried, cycles = [], [], []
for issue in scoped:
if issue.get("state") == "closed":
cycle = None
start = first_in_progress.get(issue["number"])
end = closed_at.get(issue["number"])
if start is None:
start = _parse_datetime(str(issue.get("created_at") or ""))
if end is None:
end = _parse_datetime(str(issue.get("closed_at") or ""))
if start is not None and end is not None:
cycle = round((end - start).total_seconds() / 86400, 1)
if cycle < 0:
cycle = None
else:
cycles.append(cycle)
delivered.append(
{
"number": issue["number"],
"title": issue.get("title", ""),
"html_url": issue.get("html_url", ""),
"cycle_days": cycle,
}
)
else:
col = placement.get(issue["number"], "backlog")
carried.append(
{
"number": issue["number"],
"title": issue.get("title", ""),
"column_label": COLUMN_LABELS[col],
}
)
delivered.sort(key=lambda d: d["number"])
carried.sort(key=lambda d: d["number"])
if event_dates:
period = (
f"du {min(event_dates).strftime('%Y-%m-%d')} "
f"au {max(event_dates).strftime('%Y-%m-%d')}"
)
else:
period = "aucun événement journalisé pour ce milestone"
avg_cycle = round(sum(cycles) / len(cycles), 1) if cycles else None
markdown = sprint_service.format_sprint_markdown(
full_name,
selected.get("title", milestone),
selected.get("due_on"),
period,
delivered,
carried,
counts,
avg_cycle,
)
return selected.get("title", milestone), markdown
@app.get("/api/sprint/{owner}/{repo}")
async def api_sprint(owner: str, repo: str, milestone: str = ""):
"""The sprint story as JSON markdown (issue #4)."""
result = await _sprint_markdown(owner, repo, milestone)
if result is None:
return JSONResponse(
{"ok": False, "error": "Milestone introuvable"}, status_code=404
)
title, markdown = result
return {"ok": True, "milestone": title, "markdown": markdown}
@app.get("/sprint/{owner}/{repo}", response_class=HTMLResponse)
async def sprint_page(request: Request, owner: str, repo: str, milestone: str = ""):
"""Sprint story page: rendered narrative + copy/download."""
full_name = f"{owner}/{repo}"
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
result = (
await _sprint_markdown(owner, repo, milestone, client)
if milestone
else None
)
milestones = []
if result is None:
try:
milestones = await client.get_milestones(owner, repo)
except Exception:
milestones = []
title, markdown = result if result else ("", "")
return render(
request,
"sprint.html",
full_name=full_name,
milestone_title=title,
markdown=markdown,
milestones=milestones,
milestone_param=milestone,
)
async def _apply_rule_actions(
summary: dict, rule: dict, full_name: str, owner: str, repo: str,
issue: dict, column: str, columns: dict, ages: dict,
) -> None:
"""Execute one rule's actions on one card (issue #8)."""
then = rule["then"]
number = issue["number"]
acted: list[str] = []
if then.get("add_label"):
label = then["add_label"]
present = [
(lab or {}).get("name") for lab in issue.get("labels") or []
]
if label not in present:
lab = await gitea.ensure_label(owner, repo, label, "fb8500")
await gitea.add_issue_label(owner, repo, number, lab["id"])
acted.append(f"étiquette « {label} » ajoutée")
if then.get("move_top"):
order = [c["number"] for c in columns.get(column, [])]
if order and order[0] != number:
set_column_order(
full_name, column,
[number] + [n for n in order if n != number],
)
acted.append("remontée en tête de colonne")
if then.get("notify"):
guard = (
f"rule_notified:{rule['name']}:{full_name}:{number}:{_today_str()}"
)
if not get_kv(guard):
age = (ages.get(number) or {}).get("days")
age_txt = f" depuis {age} j" if age is not None else ""
text = (
f"⚙️ Règle « {rule['name']} »\n"
f"{full_name} #{number} « {issue.get('title', '')} » — "
f"{COLUMN_LABELS[column]}{age_txt}"
)
if await send_ntfy(get_settings(), text):
set_kv(guard, "1")
acted.append("notification ntfy envoyée")
if acted:
log_event(
full_name, number, "rule",
from_column=column, to_column=column, source="rule",
)
summary["actions"].append(
{
"rule": rule["name"],
"repo": full_name,
"number": number,
"column": column,
"did": acted,
}
)
async def _run_rules_once() -> dict:
"""Evaluate every configured rule once (scheduler + manual run)."""
settings = get_settings()
rules, errors = rules_service.parse_rules(settings.rules_raw)
summary: dict = {
"at": datetime.now().strftime("%Y-%m-%d %H:%M"),
"rules": len(rules),
"errors": errors,
"actions": [],
}
if rules:
repos = await gitea.search_repos()
active, _inactive, _archived = classify_repos(repos)
for repo_info in active[:50]:
full_name = repo_info["full_name"]
owner, _, name = full_name.partition("/")
try:
all_items = await gitea.get_repo_issues(
owner, name, state="all"
)
except Exception:
continue
columns, display_issues, board_state = _board_columns_for(
owner, name, all_items
)
ages = _card_ages(full_name, display_issues, board_state)
for rule in rules:
matches = rules_service.matching_cards(rule, columns, ages)
for issue, column in matches:
if len(summary["actions"]) >= 50:
break
try:
await _apply_rule_actions(
summary, rule, full_name, owner, name,
issue, column, columns, ages,
)
except Exception as exc:
logger.warning(
"Rule %s failed on %s#%s: %s",
rule["name"], full_name, issue["number"], exc,
)
set_kv("rules_last_run", json.dumps(summary, ensure_ascii=False))
return summary
async def _rules_scheduler() -> None:
"""Evaluate the automation rules every 15 minutes (issue #8)."""
while True:
try:
if get_settings().rules_raw.strip():
await _run_rules_once()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Rules evaluation failed")
await asyncio.sleep(900)
@app.get("/rules", response_class=HTMLResponse)
async def rules_page(request: Request):
"""Automation rules: configuration status + last evaluation."""
settings = get_settings()
rules, errors = rules_service.parse_rules(settings.rules_raw)
last_run = None
raw = get_kv("rules_last_run")
if raw:
try:
last_run = json.loads(raw)
except ValueError:
last_run = None
return render(
request,
"rules.html",
rules=rules,
errors=errors,
configured=bool(settings.rules_raw.strip()),
last_run=last_run,
)
@app.post("/api/rules/run")
async def api_rules_run(request: Request, csrf_token: str = Form("")):
"""Evaluate the rules right now (manual trigger)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
summary = await _run_rules_once()
return {"ok": True, "summary": summary}
async def _project_health(
owner: str,
repo: str,
client=None,
workspace_id: int = 1,
connection_id: int = 1,
) -> dict:
"""Bottlenecks + zombies for one project (issue #9)."""
full_name = f"{owner}/{repo}"
settings = get_settings()
client = client or gitea
all_items = await client.get_repo_issues(owner, repo, state="all")
columns, display_issues, _bs = _board_columns_for(
owner, repo, all_items,
workspace_id=workspace_id, connection_id=connection_id,
)
events = get_events(repo_full_name=full_name, limit=2000)
dwell = flow_service.summarize_dwell(flow_service.column_dwell(events))
slowest = flow_service.slowest_column(dwell)
activity = flow_service.last_activity(events)
placement = {
issue["number"]: col
for col, cards in columns.items()
for issue in cards
}
now = datetime.now()
zombies = []
for issue in display_issues:
number = issue["number"]
candidates = [
ts for ts in (
activity.get((full_name, number)),
flow_service.parse_ts(issue.get("updated_at")),
flow_service.parse_ts(issue.get("created_at")),
)
if ts is not None
]
if not candidates:
continue
days = (now - max(candidates)).days
if days >= settings.zombie_days:
column = placement.get(number, "backlog")
zombies.append(
{
"number": number,
"title": issue.get("title", ""),
"column": column,
"column_label": COLUMN_LABELS[column],
"days_inactive": days,
"html_url": issue.get("html_url", ""),
}
)
zombies.sort(key=lambda z: -z["days_inactive"])
return {
"full_name": full_name,
"dwell": dwell,
"slowest": slowest,
"zombies": zombies,
"zombie_days": settings.zombie_days,
"open_count": len(display_issues),
"column_labels": COLUMN_LABELS,
}
@app.get("/health", response_class=HTMLResponse)
async def health_index(request: Request):
"""Flow health, global summary (issue #9)."""
workspace, _role = _current_workspace(request)
ws_id = workspace["id"] if workspace else 1
repos, _ws = await _workspace_repos(request)
active, _inactive, _archived = classify_repos(repos)
async def _one_health(repo_info: dict) -> dict:
owner, _, name = repo_info["full_name"].partition("/")
cid = repo_info.get("_conn_id", registry.default_id)
return await _project_health(
owner, name, _forge_client(cid),
workspace_id=ws_id, connection_id=cid,
)
results = await _parallel_map(active, _one_health)
projects = []
for repo_info, result in zip(active, results):
if isinstance(result, Exception):
logger.warning(
"Health failed for %s: %s", repo_info["full_name"], result
)
continue
result["dot"] = _health_dot(result)
projects.append(result)
severity_rank = {"red": 0, "orange": 1, "green": 2}
projects.sort(
key=lambda p: (
severity_rank.get(p["dot"]["level"], 3),
-len(p["zombies"]),
)
)
return render(request, "health.html", projects=projects)
@app.get("/health/{owner}/{repo}", response_class=HTMLResponse)
async def health_project(request: Request, owner: str, repo: str):
"""Flow health of one project: dwell per column + zombies.
Passes the active project list so the page can offer a project
switcher — no more back-to-the-index round trip (0.19.0, C).
"""
repos, _ws = await _workspace_repos(request)
active, _inactive, _archived = classify_repos(repos)
project_names = [r["full_name"] for r in active]
if f"{owner}/{repo}" not in {r["full_name"] for r in repos}:
return render(
request, "health_project.html",
health=None, full_name=f"{owner}/{repo}",
project_names=project_names,
)
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
health = await _project_health(
owner, repo, client, workspace_id=ws_id, connection_id=conn_id
)
return render(
request, "health_project.html",
health=health, full_name=f"{owner}/{repo}",
project_names=project_names,
)
@app.get("/api/standup")
async def api_standup():
"""The automatic standup as JSON text (issue #6)."""
data = await _briefing_data()
text = format_standup_text(await _standup_data(data))
return {"ok": True, "text": text}
@app.get("/standup", response_class=HTMLResponse)
async def standup_page(request: Request):
"""Standup page: yesterday / today / blockers, copy in one click."""
data = await _briefing_data()
text = format_standup_text(await _standup_data(data))
return render(
request,
"standup.html",
text=text,
include_in_briefing=get_settings().briefing_include_standup,
)
@app.get("/forecast", response_class=HTMLResponse)
async def forecast_page(request: Request):
"""Forecasts (issue #7; portfolio table in 0.19.0, chantier C).
One row per open milestone across every active project,
sorted by risk (largest ETA-vs-deadline gap first) — the
cross-project question the old per-project card wall could
not answer without reading everything. Per-project pace
(weekly bars + backlog ETA) stays as a compact second table.
"""
repos, _ws = await _workspace_repos(request)
active, _inactive, _archived = classify_repos(repos)
async def _fetch_milestones(repo_info: dict) -> list[dict]:
owner, _, name = repo_info["full_name"].partition("/")
client = _forge_client(
repo_info.get("_conn_id", registry.default_id)
)
try:
return await client.get_milestones(owner, name)
except Exception:
return []
ms_results = await _parallel_map(active, _fetch_milestones)
rows: list[dict] = []
projects: list[dict] = []
for repo_info, ms_list in zip(active, ms_results):
full = repo_info["full_name"]
owner, _, name = full.partition("/")
weekly = forecast_service.weekly_closures(
get_events(repo_full_name=full, limit=2000)
)
max_count = max((b["count"] for b in weekly), default=0)
bars = [
{
"label": b["start"].strftime("S%V"),
"count": b["count"],
"h": round(100 * b["count"] / max_count) if max_count else 0,
}
for b in weekly
]
stats = forecast_service.throughput_stats(weekly)
remaining = repo_info.get("open_issues_count") or 0
est = forecast_service.estimate(remaining, weekly)
projects.append(
{
"full_name": full,
"bars": bars,
"stats": stats,
"remaining": remaining,
"eta_label": forecast_service.eta_label(est),
}
)
if isinstance(ms_list, Exception):
ms_list = []
next_title = None
next_due = None
for ms in ms_list:
if ms.get("state") not in (None, "open"):
continue
rem = ms.get("open_issues") or 0
closed_n = ms.get("closed_issues") or 0
if rem == 0 and not closed_n:
continue
total = rem + closed_n
m_est = forecast_service.estimate(rem, weekly)
due_raw = ms.get("due_on")
due_dt = _parse_datetime(str(due_raw)) if due_raw else None
due_date = due_dt.date() if due_dt else None
gap_days = None
verdict = "noeta"
if due_date is None:
verdict = "nodue"
elif m_est and not m_est.get("done"):
gap_days = (m_est["central"] - due_date).days
verdict = "late" if gap_days > 0 else "ontime"
elif m_est and m_est.get("done"):
verdict = "ontime"
rows.append(
{
"project": full,
"owner": owner,
"repo": name,
"title": ms.get("title", ""),
"due_label": (
forecast_service.french_date(due_date, short=True)
if due_date else "—"
),
"due_sort": due_date.isoformat() if due_date else "9999",
"remaining": rem,
"closed": closed_n,
"total": total,
"pct": round(100 * closed_n / total) if total else 0,
"eta": forecast_service.eta_label(m_est),
"gap_days": gap_days,
"verdict": verdict,
"throughput": stats["avg"] if stats else None,
"anchor": False,
}
)
if (
due_date is not None
and (next_due is None or due_date < next_due)
):
next_due, next_title = due_date, ms.get("title", "")
for row in rows:
if (
row["project"] == full
and row["title"] == next_title
and next_title is not None
):
row["anchor"] = True
def _risk_key(row: dict):
# Late first (worst gap first), then dated on-time by due
# date, then no-ETA, then no due date.
order = {"late": 0, "ontime": 1, "noeta": 2, "nodue": 3}
return (
order.get(row["verdict"], 4),
-(row["gap_days"] or 0) if row["verdict"] == "late" else 0,
row["due_sort"],
row["project"],
row["title"],
)
rows.sort(key=_risk_key)
summary = {
"late": sum(1 for r in rows if r["verdict"] == "late"),
"ontime": sum(1 for r in rows if r["verdict"] == "ontime"),
"total": len(rows),
}
return render(
request, "forecast.html",
rows=rows, projects=projects, summary=summary,
)
@app.get("/focus", response_class=HTMLResponse)
async def focus(request: Request):
"""Cross-project view: everything in progress or in review."""
workspace, _role = _current_workspace(request)
ws_id = workspace["id"] if workspace else 1
ws_repos, _ws = await _workspace_repos(request)
return render(
request,
"focus.html",
groups=await _collect_focus_groups(ws_repos, ws_id),
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
)
def _group_issue_results(issues: list[dict]) -> list[dict]:
"""Group search results by repo, annotated with the board column."""
by_repo: dict[str, list[dict]] = {}
for issue in issues:
full_name = (issue.get("repository") or {}).get("full_name", "?")
by_repo.setdefault(full_name, []).append(issue)
groups = []
for full_name in sorted(by_repo):
state = get_board_state(full_name) if "/" in full_name else {}
column_of = {
card["issue_number"]: column
for column, cards in state.items()
for card in cards
}
rows = []
for issue in by_repo[full_name]:
if issue.get("state") == "open":
column = column_of.get(issue["number"], "backlog")
else:
column = "done"
rows.append({"issue": issue, "column": column})
groups.append({"full_name": full_name, "rows": rows})
return groups
WALL_COLUMNS = ["in_progress", "qa", "review", "todo"]
async def _collect_wall_data(
only_repo: str | None,
annotated_repos: list[dict] | None = None,
workspace_id: int = 1,
) -> list[dict]:
"""Slides for the wallboard: one per active project.
Each slide carries the column counts, the cards in flight
(in progress / QA / review / todo, in flow order) with their age
in column, and how many cards were closed today (journal).
"""
if annotated_repos is None:
annotated_repos = [
{**r, "_conn_id": registry.default_id}
for r in await gitea.search_repos()
]
if only_repo:
if _split_full_name(only_repo) is None:
return []
match = [
r for r in annotated_repos if r.get("full_name") == only_repo
]
if not match:
return []
repos = match
else:
active, _inactive, _archived = classify_repos(annotated_repos)
repos = active[:12]
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
closed_today: dict[str, int] = {}
for event in get_events(limit=500):
if event["event_type"] == "closed" and str(
event["created_at"]
).startswith(today):
closed_today[event["repo_full_name"]] = (
closed_today.get(event["repo_full_name"], 0) + 1
)
async def _one_slide(repo: dict) -> dict | None:
full_name = repo["full_name"]
owner, _, name = full_name.partition("/")
cid = repo.get("_conn_id", registry.default_id)
client = _forge_client(cid)
try:
items = await client.get_repo_issues(owner, name, state="open")
except Exception as exc:
logger.debug("wall: issues failed for %s: %s", full_name, exc)
return None
open_issues = [i for i in items if _is_issue(i)]
_adopt_label_columns(
full_name, open_issues,
get_board_state(
full_name, workspace_id=workspace_id, connection_id=cid
),
workspace_id=workspace_id, connection_id=cid,
)
board_state = get_board_state(
full_name, workspace_id=workspace_id, connection_id=cid
)
columns = _merge_issues_with_board(open_issues, board_state)
ages = _card_ages(
full_name, open_issues, board_state,
workspace_id=workspace_id, connection_id=cid,
)
aging_days = get_settings().aging_days
cards = []
for col in WALL_COLUMNS:
for issue in columns[col]:
age = ages.get(issue["number"]) or {}
days = age.get("days")
cards.append(
{
"number": issue["number"],
"title": issue.get("title", ""),
"column": col,
"column_label": COLUMN_LABELS[col],
"age_days": days,
"aging": days is not None and days >= aging_days,
}
)
return {
"full_name": full_name,
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
"open_total": len(open_issues),
"cards": cards[:9],
"cards_total": len(cards),
"closed_today": closed_today.get(full_name, 0),
}
results = await _parallel_map(repos, _one_slide)
return [r for r in results if isinstance(r, dict)]
@app.get("/wall", response_class=HTMLResponse)
async def wall(request: Request, repo: str = ""):
"""Wallboard: full-screen, auto-rotating, live project display."""
workspace, _role = _current_workspace(request)
ws_id = workspace["id"] if workspace else 1
ws_repos, _ws = await _workspace_repos(request)
slides = await _collect_wall_data(repo or None, ws_repos, ws_id)
return render(
request,
"wall.html",
slides=slides,
single=bool(repo),
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
columns=KANBAN_COLUMNS,
)
@app.get("/search", response_class=HTMLResponse)
async def search_page(request: Request, q: str = ""):
"""Global issue search across the workspace's forges."""
q = q.strip()
groups: list[dict] = []
total = 0
if q:
workspace, _role = _current_workspace(request)
conn_ids = (
models.workspace_connection_ids(workspace["id"])
if workspace
else [registry.default_id]
)
results: list[dict] = []
for cid in conn_ids:
try:
found = await _forge_client(cid).search_issues(
q=q, state="all"
)
results.extend(found)
except Exception as exc:
logger.debug("search failed for conn %s: %s", cid, exc)
total = len(results)
groups = _group_issue_results(results)
return render(
request,
"search.html",
q=q,
groups=groups,
total=total,
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
)
@app.get("/my", response_class=HTMLResponse)
async def my_issues(request: Request):
"""Open issues assigned to the token owner, per connection."""
user = None
groups: list[dict] = []
total = 0
workspace, _role = _current_workspace(request)
conn_ids = (
models.workspace_connection_ids(workspace["id"])
if workspace
else [registry.default_id]
)
results: list[dict] = []
for cid in conn_ids:
client = _forge_client(cid)
try:
conn_user = await client.get_user()
except Exception as exc:
logger.debug("get_user failed for /my (conn %s): %s", cid, exc)
continue
if user is None:
user = conn_user
login = conn_user.get("login") or conn_user.get("username")
if login:
try:
results.extend(
await client.search_issues(
state="open", assigned_by=login
)
)
except Exception as exc:
logger.debug("search failed for /my (conn %s): %s", cid, exc)
total = len(results)
groups = _group_issue_results(results)
return render(
request,
"my.html",
user=user,
groups=groups,
total=total,
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
)
# ── Briefing ───────────────────────────────────────────────
async def _briefing_data() -> dict:
"""Everything the daily briefing needs, gathered in one dict."""
settings = get_settings()
focus_groups = await _collect_focus_groups()
repos = await gitea.search_repos()
active, _i, _a = classify_repos(repos)
soon = datetime.now(timezone.utc) + timedelta(days=7)
aging: list[dict] = []
milestones_due: list[dict] = []
async def _per_repo(repo: dict):
full_name = repo["full_name"]
owner, _, name = full_name.partition("/")
items = await gitea.get_repo_issues(owner, name, state="open")
open_issues = [i for i in items if _is_issue(i)]
ages = _card_ages(full_name, open_issues, get_board_state(full_name))
board = get_board_state(full_name)
column_of = {
card["issue_number"]: column
for column, cards in board.items()
for card in cards
}
repo_aging = [
{
"repo": full_name,
"number": issue["number"],
"title": issue["title"],
"days": ages[issue["number"]]["days"],
"column_label": COLUMN_LABELS[
column_of.get(issue["number"], "backlog")
],
}
for issue in open_issues
if issue["number"] in ages
and ages[issue["number"]]["days"] >= settings.aging_days
]
repo_milestones = []
try:
for ms in await gitea.get_milestones(owner, name):
due = _parse_datetime(ms.get("due_on"))
if due and due <= soon and (ms.get("open_issues") or 0) > 0:
repo_milestones.append(
{
"repo": full_name,
"title": ms.get("title", ""),
"due_on": str(ms["due_on"])[:10],
"open": ms.get("open_issues") or 0,
}
)
except Exception as exc:
logger.debug("briefing milestones failed for %s: %s", full_name, exc)
return repo_aging, repo_milestones
results = await asyncio.gather(
*(_per_repo(r) for r in active[:100]), return_exceptions=True
)
for result in results:
if isinstance(result, Exception):
logger.warning("briefing: repo fetch failed: %s", result)
continue
repo_aging, repo_milestones = result
aging.extend(repo_aging)
milestones_due.extend(repo_milestones)
aging.sort(key=lambda c: -c["days"])
metrics_24h = get_metrics(window_days=1)
metrics_30d = get_metrics(window_days=30)
# Focus of the day (issue #5): today's pins + yesterday's
# unfinished picks, proposed again in the morning briefing.
today_picks: list[dict] = []
for pick in get_picks(_today_str()):
enriched = await _enrich_pick(pick)
if enriched:
today_picks.append(enriched)
picked_keys = {(p["repo"], p["number"]) for p in today_picks}
carry_over: list[dict] = []
for pick in get_picks(_today_str(-1)):
if (pick["repo_full_name"], pick["issue_number"]) in picked_keys:
continue
enriched = await _enrich_pick(pick)
if enriched and enriched["state"] != "closed":
carry_over.append(enriched)
data = {
"date": datetime.now().strftime("%d/%m/%Y"),
"today_picks": today_picks,
"carry_over": carry_over,
"focus_total": sum(len(g["cards"]) for g in focus_groups),
"focus_groups": [
{
"full_name": g["full_name"],
"count": len(g["cards"]),
"titles": [c["issue"]["title"] for c in g["cards"]],
}
for g in focus_groups
],
"aging": aging[:10],
"milestones_due": milestones_due[:5],
"activity_24h": {
**metrics_24h,
"avg_cycle_days": metrics_30d["avg_cycle_days"],
},
}
if get_settings().briefing_include_standup:
data["standup"] = format_standup_text(await _standup_data(data))
return data
async def _standup_data(briefing_data: dict) -> dict:
"""Yesterday / today / blockers for the automatic standup (#6)."""
yesterday = datetime.now() - timedelta(days=1)
y_str = yesterday.strftime("%Y-%m-%d")
events = [
e for e in get_events(limit=1000)
if str(e.get("created_at") or "").startswith(y_str)
]
closed_keys: list[tuple[str, int]] = []
seen: set[tuple[str, int]] = set()
moved = 0
for event in events:
if event["event_type"] == "closed":
key = (event["repo_full_name"], event["issue_number"])
if key not in seen:
seen.add(key)
closed_keys.append(key)
elif event["event_type"] == "moved":
moved += 1
titles: dict[tuple[str, int], str] = {}
for repo_name in {repo for repo, _ in closed_keys}:
owner, _, name = repo_name.partition("/")
try:
items = await gitea.get_repo_issues(owner, name, state="all")
except Exception:
continue
for item in items:
if _is_issue(item):
titles[(repo_name, item["number"])] = item.get("title", "")
blockers = [
card for card in briefing_data.get("aging", [])
if "Révision" in card.get("column_label", "")
or "QA" in card.get("column_label", "")
]
focus_titles = [
f"{g['full_name']} — {title}"
for g in briefing_data.get("focus_groups", [])
for title in g.get("titles", [])[:2]
]
return {
"today_label": datetime.now().strftime("%A %d %B %Y"),
"yesterday_label": yesterday.strftime("%A %d %B"),
"closed_yesterday": [
{
"repo": repo,
"number": number,
"title": titles.get((repo, number), ""),
}
for repo, number in closed_keys
],
"moved_yesterday": moved,
"picks": briefing_data.get("today_picks", []),
"focus_titles": focus_titles,
"blockers": blockers,
}
@app.get("/briefing", response_class=HTMLResponse)
async def briefing_page(request: Request):
"""The daily briefing, readable on the web before it is pushed."""
settings = get_settings()
data = await _briefing_data()
return render(
request,
"briefing.html",
data=data,
text=format_briefing_text(data),
ntfy_configured=ntfy_configured(settings),
briefing_time=settings.briefing_time,
last_sent=get_kv("briefing_last_sent"),
)
@app.get("/api/briefing")
async def api_briefing():
data = await _briefing_data()
return {"ok": True, "briefing": data, "text": format_briefing_text(data)}
@app.post("/api/briefing/send")
async def api_briefing_send(request: Request, csrf_token: str = Form("")):
"""Send the briefing to the configured ntfy topic, right now."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
settings = get_settings()
if not ntfy_configured(settings):
return JSONResponse(
{
"ok": False,
"error": "ntfy is not configured "
"(KANBANTE_NTFY_URL / KANBANTE_NTFY_TOPIC)",
},
status_code=400,
)
data = await _briefing_data()
sent = await send_ntfy(settings, format_briefing_text(data))
if not sent:
return JSONResponse(
{"ok": False, "error": "ntfy server rejected or unreachable"},
status_code=502,
)
set_kv("briefing_last_sent", datetime.now().strftime("%Y-%m-%d"))
return {"ok": True, "sent": True}
# ── Webhooks + live stream ─────────────────────────────────
def _apply_external_transition(
full_name: str,
number: int,
target_column: str,
event_type: str,
workspace_id: int = 1,
connection_id: int = 1,
) -> str:
"""Apply a close/reopen that happened in Gitea itself.
Idempotent by construction: when Kanbante's own API made the
change, the local row is already in the target state and this is
a no-op — so our own webhook echo never double-logs.
"""
board = get_board_state(
full_name, workspace_id=workspace_id, connection_id=connection_id
)
current = None
for column, cards in board.items():
if any(c["issue_number"] == number for c in cards):
current = column
break
if current is None or current == target_column:
return "noop"
if event_type == "reopened" and current != "done":
return "noop" # locally open already: nothing truthful to apply
move_issue(
full_name, number, target_column,
workspace_id=workspace_id, connection_id=connection_id,
)
if event_type == "closed":
log_event(
full_name, number, "closed", to_column="done", source="webhook",
workspace_id=workspace_id, connection_id=connection_id,
)
else:
log_event(
full_name, number, "reopened",
from_column="done", to_column=target_column, source="webhook",
workspace_id=workspace_id, connection_id=connection_id,
)
return event_type
async def _apply_pr_merged(
full_name: str,
pr: dict,
client=None,
workspace_id: int = 1,
connection_id: int = 1,
) -> list[str]:
"""A merged PR sends its linked cards to done (issue #2).
Links come from #n references in the PR title/body and from the
head branch name (issue-n-*). The issue is closed in Gitea when
it is not already (a plain reference does not auto-close), the
card is moved idempotently, and the journal records the webhook
as the source.
"""
refs = code_links_service.extract_issue_refs(
pr.get("title")
) | code_links_service.extract_issue_refs(pr.get("body"))
branch_number = code_links_service.branch_issue_number(
(pr.get("head") or {}).get("ref")
)
if branch_number is not None:
refs.add(branch_number)
client = client or gitea
owner, _, repo = full_name.partition("/")
results: list[str] = []
for number in sorted(refs):
try:
issue = await client.get_issue(owner, repo, number)
except Exception:
results.append(f"#{number}:unknown")
continue
if not _is_issue(issue):
results.append(f"#{number}:not-issue")
continue
if issue.get("state") != "closed":
try:
await client.set_issue_state(owner, repo, number, "closed")
except Exception as exc:
logger.warning(
"PR merge: closing %s#%s failed: %s", full_name, number, exc
)
outcome = _apply_external_transition(
full_name, number, "done", "closed",
workspace_id=workspace_id, connection_id=connection_id,
)
if outcome == "noop":
board = get_board_state(
full_name, workspace_id=workspace_id,
connection_id=connection_id,
)
placed = any(
c["issue_number"] == number
for cards in board.values()
for c in cards
)
if not placed:
move_issue(
full_name, number, "done",
workspace_id=workspace_id, connection_id=connection_id,
)
log_event(
full_name, number, "closed",
to_column="done", source="webhook",
workspace_id=workspace_id, connection_id=connection_id,
)
outcome = "closed"
results.append(f"#{number}:{outcome}")
return results
@app.post("/webhooks/gitea")
async def gitea_webhook(request: Request):
"""Legacy webhook route (0.24.0: connection #1, slug "gitea").
Kept identical for existing Gitea configurations; new
connections use /webhooks/{slug} (GitHub included).
"""
row = models.get_connection_by_slug("gitea")
return await _handle_forge_webhook(request, row)
@app.post("/webhooks/{slug}")
async def forge_webhook(request: Request, slug: str):
"""Per-connection webhook (0.24.0, P0/P1)."""
row = models.get_connection_by_slug(slug)
if row is None or not row["enabled"]:
return JSONResponse(
{"ok": False, "error": "Unknown connection"}, status_code=404
)
return await _handle_forge_webhook(request, row)
async def _handle_forge_webhook(request: Request, conn_row):
"""Verify HMAC, apply external close/reopen in every workspace
holding the connection, invalidate the connection's cache and
push a live event to browsers."""
settings = get_settings()
raw = await request.body()
kind = conn_row["kind"] if conn_row else "gitea"
secret = (conn_row or {}).get("webhook_secret") or settings.webhook_secret
if secret:
if kind == "github":
signature = request.headers.get("x-hub-signature-256", "")
expected = "sha256=" + hmac.new(
secret.encode(), raw, hashlib.sha256
).hexdigest()
else:
signature = request.headers.get("x-gitea-signature", "")
expected = hmac.new(
secret.encode(), raw, hashlib.sha256
).hexdigest()
if not signature or not hmac.compare_digest(signature, expected):
return JSONResponse(
{"ok": False, "error": "Invalid webhook signature"},
status_code=401,
)
try:
payload = json.loads(raw or b"{}")
except ValueError:
return JSONResponse(
{"ok": False, "error": "Invalid JSON payload"}, status_code=400
)
event_header = "x-github-event" if kind == "github" else "x-gitea-event"
event = request.headers.get(event_header, "")
action = payload.get("action", "")
full_name = (payload.get("repository") or {}).get("full_name", "")
issue = payload.get("issue") or {}
number = issue.get("number")
connection_id = conn_row["id"] if conn_row else 1
client = registry.client_for(connection_id) or gitea
workspace_ids = [
ws["id"]
for ws in models.list_workspaces()
if connection_id in models.workspace_connection_ids(ws["id"])
] or [1]
handled = "ignored"
if event == "issues" and full_name and number:
outcomes = []
for ws_id in workspace_ids:
if action == "closed":
outcomes.append(
_apply_external_transition(
full_name, number, "done", "closed",
workspace_id=ws_id, connection_id=connection_id,
)
)
elif action == "reopened":
outcomes.append(
_apply_external_transition(
full_name, number, "backlog", "reopened",
workspace_id=ws_id, connection_id=connection_id,
)
)
if outcomes:
handled = outcomes[0]
elif event == "pull_request" and action == "closed" and full_name:
pr = payload.get("pull_request") or {}
if pr.get("merged"):
results: list[str] = []
for ws_id in workspace_ids:
results = await _apply_pr_merged(
full_name, pr, client=client,
workspace_id=ws_id, connection_id=connection_id,
)
handled = "pr-merged:" + (",".join(results) if results else "none")
client.invalidate_cache()
# Chantier B: a webhook means this repo just changed — refresh
# its snapshots right away instead of waiting for the interval.
if full_name and "/" in full_name:
_owner, _, _repo = full_name.partition("/")
asyncio.create_task(
snapshots_service.refresh_repo_snapshots(client, _owner, _repo)
)
await hub.broadcast(
{"repo": full_name, "event": event, "action": action, "issue": number}
)
return {"ok": True, "handled": handled}
async def sse_event_stream(request: Request):
"""Yield SSE frames for one subscriber until it disconnects.
Split out of the route so it can be unit-tested directly:
test clients cannot reliably tear down an infinite HTTP stream.
"""
queue = hub.subscribe()
try:
yield "retry: 5000\n\n"
while True:
try:
payload = await asyncio.wait_for(queue.get(), timeout=25)
except asyncio.TimeoutError:
if await request.is_disconnected():
break
yield ": heartbeat\n\n"
continue
if await request.is_disconnected():
break
yield f"event: gitea\ndata: {json.dumps(payload)}\n\n"
finally:
hub.unsubscribe(queue)
async def _do_capture(
title: str, body: str, repo_full_name: str, column: str = "backlog"
) -> dict:
"""Create a Gitea issue from a capture and place its card.
Shared by POST /api/capture, the /capture page and the ntfy
listener. Raises ValueError for bad input (callers map to 400).
"""
title = (title or "").strip()
if not title or len(title) > 255:
raise ValueError("Title required (max 255 chars)")
if column not in KANBAN_COLUMNS:
raise ValueError("Invalid column")
parts = _split_full_name(repo_full_name)
if parts is None:
raise ValueError("Invalid repo (expected owner/name)")
owner, repo = parts
# Capture has no board context: find the connection holding
# this repo among the global ones (default first), so a card
# can be captured into a GitHub project too (0.24.0).
client, conn_id = gitea, registry.default_id
for candidate in registry.global_clients():
if candidate is gitea:
continue
try:
names = {
r.get("full_name") for r in await candidate.search_repos()
}
except Exception:
continue
if repo_full_name in names:
client, conn_id = candidate, candidate.connection_id
break
issue = await client.create_issue(owner, repo, title, body or "")
# La carte vit dans le premier espace qui détient la connexion.
ws_id = next(
(
ws["id"]
for ws in models.list_workspaces()
if conn_id in models.workspace_connection_ids(ws["id"])
),
1,
)
move_issue(
repo_full_name, issue["number"], column,
workspace_id=ws_id, connection_id=conn_id,
)
log_event(
repo_full_name, issue["number"], "created",
to_column=column, source="capture",
workspace_id=ws_id, connection_id=conn_id,
)
await _publish_board_changed(repo_full_name)
return {
"number": issue["number"],
"title": issue.get("title", title),
"html_url": issue.get("html_url", ""),
"repo_full_name": repo_full_name,
"column": column,
}
@app.post("/api/capture")
async def api_capture(request: Request):
"""Quick capture endpoint (roadmap #12).
Auth: dedicated capture token (X-Capture-Token header or
Authorization: Bearer), distinct from the Gitea token. Exempt
from Basic Auth and CSRF — it is a machine-to-machine endpoint.
Disabled (503) until KANBANTE_CAPTURE_TOKEN is set.
"""
settings = get_settings()
if not settings.capture_token:
return JSONResponse(
{"ok": False, "error": "Capture is not configured "
"(set KANBANTE_CAPTURE_TOKEN)"},
status_code=503,
)
provided = request.headers.get("x-capture-token", "")
auth = request.headers.get("authorization", "")
if not provided and auth.lower().startswith("bearer "):
provided = auth[7:].strip()
if not provided or not secrets.compare_digest(provided, settings.capture_token):
return JSONResponse(
{"ok": False, "error": "Invalid capture token"}, status_code=401
)
content_type = request.headers.get("content-type", "")
title = body = text = repo = ""
column = "backlog"
if "application/json" in content_type:
try:
payload = await request.json()
except Exception:
payload = {}
if isinstance(payload, dict):
title = str(payload.get("title") or "")
body = str(payload.get("body") or "")
text = str(payload.get("text") or "")
repo = str(payload.get("repo") or "")
column = str(payload.get("column") or "backlog")
elif "application/x-www-form-urlencoded" in content_type or (
"multipart/form-data" in content_type
):
form = await request.form()
title = str(form.get("title") or "")
body = str(form.get("body") or "")
text = str(form.get("text") or "")
repo = str(form.get("repo") or "")
column = str(form.get("column") or "backlog")
else: # text/plain and friends: the whole body is the capture
text = (await request.body()).decode("utf-8", errors="replace")
if not title and text:
title, rest = parse_capture_text(text)
body = body or rest
repo = repo or settings.capture_repo
if not repo:
return JSONResponse(
{"ok": False, "error": "No repo given and KANBANTE_CAPTURE_REPO "
"is not set"},
status_code=400,
)
try:
created = await _do_capture(title, body, repo, column)
except ValueError as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
return JSONResponse({"ok": True, "issue": created}, status_code=201)
@app.get("/capture", response_class=HTMLResponse)
async def capture_page(request: Request, repo: str = ""):
"""Quick-capture page: form, status and usage instructions.
`?repo=Owner/name` pre-fills the target repo (issue #23: the
project card's quick actions capture straight into a project).
"""
settings = get_settings()
return render(
request,
"capture.html",
capture_repo=repo.strip() or settings.capture_repo,
api_enabled=bool(settings.capture_token),
ntfy_topic=settings.capture_ntfy_topic,
ntfy_url=settings.ntfy_url,
created=None,
error=None,
)
@app.post("/capture", response_class=HTMLResponse)
async def capture_submit(
request: Request,
repo_full_name: str = Form(""),
title: str = Form(""),
body: str = Form(""),
column: str = Form("backlog"),
csrf_token: str = Form(""),
):
"""Web form capture (session + CSRF, like every other form)."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
settings = get_settings()
repo_full_name = repo_full_name.strip() or settings.capture_repo
created, error = None, None
if not repo_full_name:
error = "Aucun dépôt : renseigne le dépôt ou définis KANBANTE_CAPTURE_REPO."
else:
try:
created = await _do_capture(title, body, repo_full_name, column)
except ValueError as exc:
error = str(exc)
return render(
request,
"capture.html",
capture_repo=settings.capture_repo,
api_enabled=bool(settings.capture_token),
ntfy_topic=settings.capture_ntfy_topic,
ntfy_url=settings.ntfy_url,
created=created,
error=error,
)
async def _weekly_digest() -> dict:
"""Compose the weekly digest (journal of the last 7 days + focus)."""
now = datetime.now(timezone.utc)
since = (now - timedelta(days=7)).replace(tzinfo=None)
events = []
for event in get_events(limit=1000):
at = _parse_datetime(str(event.get("created_at") or ""))
if at is not None and at.replace(tzinfo=None) >= since:
events.append(event)
closed_keys: list[tuple[str, int]] = []
seen: set[tuple[str, int]] = set()
for event in events:
if event["event_type"] == "closed":
key = (event["repo_full_name"], event["issue_number"])
if key not in seen:
seen.add(key)
closed_keys.append(key)
titles: dict[tuple[str, int], dict] = {}
for repo_name in {repo for repo, _ in closed_keys}:
owner, _, name = repo_name.partition("/")
try:
items = await gitea.get_repo_issues(owner, name, state="all")
except Exception:
continue
for item in items:
if _is_issue(item):
titles[(repo_name, item["number"])] = item
closed_items = [
{
"repo": repo,
"number": number,
"title": (titles.get((repo, number)) or {}).get("title", ""),
"html_url": (titles.get((repo, number)) or {}).get("html_url", ""),
}
for repo, number in closed_keys
]
focus_groups = []
for group in await _collect_focus_groups():
focus_groups.append(
{
"full_name": group["full_name"],
"cards": [
{
"issue": card["issue"],
"column": COLUMN_LABELS[card["column"]],
}
for card in group["cards"]
],
}
)
week_label = (
f"{(now - timedelta(days=6)).strftime('%Y-%m-%d')}"
f" au {now.strftime('%Y-%m-%d')}"
)
markdown = digest_service.format_digest_markdown(
week_label,
closed_items,
created_count=sum(1 for e in events if e["event_type"] == "created"),
moved_count=sum(1 for e in events if e["event_type"] == "moved"),
focus_groups=focus_groups,
)
return {
"title": f"Kanbante — semaine du {week_label}",
"week": now.strftime("%G-W%V"),
"markdown": markdown,
"closed_count": len(closed_items),
}
def _render_digest(request: Request, banner: str | None = None,
tools: list[str] | None = None):
settings = get_settings()
return render(
request,
"digest.html",
pending=get_kv("digest_pending"),
last_result=get_kv("digest_last_result"),
obsigate_configured=bool(settings.obsigate_mcp_url),
obsigate_url=settings.obsigate_mcp_url,
obsigate_tool=settings.obsigate_mcp_tool,
webhook_configured=bool(settings.digest_webhook_url),
digest_time=settings.digest_time,
digest_weekday=settings.digest_weekday,
banner=banner,
tools=tools,
)
@app.get("/digest", response_class=HTMLResponse)
async def digest_page(request: Request):
"""Weekly digest: preview, approval and delivery status."""
return _render_digest(request)
@app.post("/digest/probe", response_class=HTMLResponse)
async def digest_probe(request: Request, csrf_token: str = Form("")):
"""List the tools of the configured ObsiGate MCP server."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
settings = get_settings()
if not settings.obsigate_mcp_url:
return _render_digest(request, banner="❌ Aucun serveur MCP ObsiGate configuré (KANBANTE_OBSIGATE_MCP_URL).")
ok, result = await digest_service.mcp_list_tools(
settings.obsigate_mcp_url, settings.obsigate_mcp_token
)
if ok:
return _render_digest(
request,
banner=f"✅ Connexion MCP réussie — {len(result)} outil(s) disponible(s).",
tools=result,
)
return _render_digest(request, banner=f"❌ {result}")
@app.post("/digest/send", response_class=HTMLResponse)
async def digest_send(
request: Request,
target: str = Form(...),
csrf_token: str = Form(""),
):
"""Deliver the digest — only ever from this explicit approval."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
settings = get_settings()
digest = await _weekly_digest()
if target == "webhook":
if not settings.digest_webhook_url:
return _render_digest(request, banner="❌ Webhook non configuré (KANBANTE_DIGEST_WEBHOOK_URL).")
ok, message = await digest_service.send_webhook(
settings.digest_webhook_url,
digest["title"], digest["markdown"], digest["week"],
)
elif target == "obsigate":
if not settings.obsigate_mcp_url or not settings.obsigate_mcp_tool:
return _render_digest(request, banner="❌ ObsiGate incomplet : KANBANTE_OBSIGATE_MCP_URL et KANBANTE_OBSIGATE_MCP_TOOL sont requis (utilise « Tester la connexion » pour voir les outils).")
try:
arguments = digest_service.render_mcp_args(
settings.obsigate_mcp_args, digest["title"], digest["markdown"]
)
except ValueError as exc:
return _render_digest(request, banner=f"❌ {exc}")
ok, message = await digest_service.mcp_call_tool(
settings.obsigate_mcp_url, settings.obsigate_mcp_token,
settings.obsigate_mcp_tool, arguments,
)
else:
return _render_digest(request, banner="❌ Cible inconnue.")
stamp = datetime.now().strftime("%Y-%m-%d %H:%M")
set_kv("digest_last_result", f"{stamp} · {target} · {message}")
if ok:
set_kv("digest_pending", "")
return _render_digest(request, banner=f"✅ Digest envoyé ({target}) : {message}")
return _render_digest(request, banner=f"❌ Échec de l'envoi ({target}) : {message}")
async def _digest_scheduler() -> None:
"""Compose the weekly digest and notify — never deliver it.
Delivery to ObsiGate is a cross-app write: it waits for the
one-click approval on /digest (standing rule).
"""
while True:
try:
settings = get_settings()
if settings.digest_time:
now = datetime.now()
week = now.strftime("%G-W%V")
if (
now.weekday() == settings.digest_weekday
and now.strftime("%H:%M") >= settings.digest_time
and get_kv("digest_pending_week") != week
):
digest = await _weekly_digest()
set_kv("digest_pending", digest["markdown"])
set_kv("digest_pending_week", week)
logger.info("Weekly digest composed for %s", week)
if ntfy_configured(settings):
await send_ntfy(
settings,
"🧠 Digest hebdo Kanbante prêt — "
"en attente d'approbation sur la page /digest.",
)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Digest scheduler error: %s", exc)
await asyncio.sleep(60)
@app.get("/api/stream")
async def live_stream(request: Request):
"""Server-Sent Events: browsers reload softly on Gitea activity."""
return StreamingResponse(
sse_event_stream(request),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.get("/activity", response_class=HTMLResponse)
async def activity(request: Request):
"""Activity feed: the latest entries of the event journal."""
events = get_events(limit=100)
return render(
request,
"activity.html",
events=events,
event_icons=EVENT_ICONS,
column_labels=COLUMN_LABELS,
)
# ── Routes: API ────────────────────────────────────────────
@app.post("/api/move")
async def api_move(
request: Request,
repo_full_name: str = Form(...),
issue_number: int = Form(...),
to_column: str = Form(...),
from_column: str = Form(""),
to_order: str = Form(""),
from_order: str = Form(""),
csrf_token: str = Form(""),
):
"""Move an issue to a new column and persist the resulting order.
With KANBANTE_SYNC_GITEA enabled (default), moving to "done" closes
the issue in Gitea and moving out of "done" reopens it. A failed
write-back is reported in the response (gitea_sync="failed") instead
of failing the move itself.
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
if to_column not in KANBAN_COLUMNS:
return JSONResponse(
{"ok": False, "error": "Invalid column"}, status_code=400
)
parts = _split_full_name(repo_full_name)
if parts is None:
return JSONResponse(
{"ok": False, "error": "Invalid repo_full_name"}, status_code=400
)
owner, repo = parts
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
move_issue(
repo_full_name, issue_number, to_column,
workspace_id=ws_id, connection_id=conn_id,
)
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,
workspace_id=ws_id, connection_id=conn_id,
)
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,
workspace_id=ws_id, connection_id=conn_id,
)
if from_column != to_column:
log_event(
repo_full_name, issue_number, "moved",
from_column or None, to_column,
workspace_id=ws_id, connection_id=conn_id,
)
result: dict = {"ok": True, "gitea_sync": "skipped"}
settings = get_settings()
if settings.sync_gitea:
try:
if to_column == "done":
await client.set_issue_state(owner, repo, issue_number, "closed")
result["gitea_sync"] = "closed"
log_event(
repo_full_name, issue_number, "closed", to_column="done",
workspace_id=ws_id, connection_id=conn_id,
)
elif from_column == "done":
await client.set_issue_state(owner, repo, issue_number, "open")
result["gitea_sync"] = "reopened"
log_event(
repo_full_name, issue_number, "reopened",
from_column="done", to_column=to_column,
workspace_id=ws_id, connection_id=conn_id,
)
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"
# Mirror intermediate columns to Gitea labels (kanban:<column>) so
# the workflow state is visible in Gitea itself, not just here.
result["label_sync"] = "skipped"
if settings.sync_labels and from_column != to_column:
try:
desired = (
f"kanban:{to_column}" if to_column in LABELLED_COLUMNS else None
)
issue = await client.get_issue(owner, repo, issue_number)
current = [
label for label in issue.get("labels") or []
if str(label.get("name", "")).startswith("kanban:")
]
for label in current:
if label.get("name") != desired:
await client.remove_issue_label(
owner, repo, issue_number, label["id"]
)
if desired and all(l.get("name") != desired for l in current):
label = await client.ensure_label(
owner, repo, desired, COLUMN_COLORS[to_column]
)
await client.add_issue_label(owner, repo, issue_number, label["id"])
result["label_sync"] = "ok"
except Exception as exc:
logger.warning(
"Label sync failed for %s#%s: %s", repo_full_name, issue_number, exc
)
result["label_sync"] = "failed"
await _publish_board_changed(repo_full_name)
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
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
issue = await client.create_issue(owner, repo, title, body)
move_issue(
repo_full_name, issue["number"], column,
workspace_id=ws_id, connection_id=conn_id,
)
log_event(
repo_full_name, issue["number"], "created", to_column=column,
workspace_id=ws_id, connection_id=conn_id,
)
await _publish_board_changed(repo_full_name)
return JSONResponse(
{
"ok": True,
"issue": {
"number": issue["number"],
"title": issue.get("title", title),
"html_url": issue.get("html_url", ""),
},
},
status_code=201,
)
def _issue_payload(issue: dict) -> dict:
assignees = issue.get("assignees") or []
if not assignees and issue.get("assignee"):
assignees = [issue["assignee"]]
milestone = issue.get("milestone") or {}
return {
"number": issue.get("number"),
"title": issue.get("title", ""),
"body": issue.get("body") or "",
"state": issue.get("state", "open"),
"html_url": issue.get("html_url", ""),
"created_at": issue.get("created_at", ""),
"updated_at": issue.get("updated_at", ""),
"due_date": issue.get("due_date"),
"labels": [
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
for l in issue.get("labels") or []
],
"assignees": [
{"login": a.get("login", ""), "avatar_url": a.get("avatar_url", "")}
for a in assignees
],
"milestone": milestone.get("title"),
}
def _comment_payload(comment: dict) -> dict:
user = comment.get("user") or {}
return {
"id": comment.get("id"),
"body": comment.get("body") or "",
"created_at": comment.get("created_at", ""),
"updated_at": comment.get("updated_at", ""),
"html_url": comment.get("html_url", ""),
"user": {
"login": user.get("login", "?"),
"avatar_url": user.get("avatar_url", ""),
},
}
async def _fetch_code_sources(owner: str, repo: str, client=None):
"""Branches + PRs + recent commits of a repo (best effort each)."""
client = client or gitea
async def _safe(coro):
try:
return await coro
except Exception as exc:
logger.debug("code links source failed for %s/%s: %s", owner, repo, exc)
return []
branches, pulls, commits = await asyncio.gather(
_safe(client.list_branches(owner, repo)),
_safe(client.list_pulls(owner, repo)),
_safe(client.list_commits(owner, repo)),
)
return branches, pulls, commits
async def _annotate_code(
owner: str, repo: str, columns: dict, client=None
) -> None:
"""Attach a compact _code summary to each card issue (issue #1)."""
branches, pulls, commits = await _fetch_code_sources(owner, repo, client)
if not (branches or pulls or commits):
return
for cards in columns.values():
for issue in cards:
links = code_links_service.find_code_links(
branches, pulls, commits, issue["number"]
)
if links["branches"] or links["pulls"] or links["commits"]:
issue["_code"] = links["summary"]
@app.get("/api/issues/{owner}/{repo}/{number}/code")
async def api_issue_code(owner: str, repo: str, number: int):
"""Branches, PRs and commits linked to a card (issue #1)."""
branches, pulls, commits = await _fetch_code_sources(owner, repo)
links = code_links_service.find_code_links(branches, pulls, commits, number)
suggested = f"issue-{number}"
try:
issue = await gitea.get_issue(owner, repo, number)
suggested = code_links_service.branch_name_for(
number, issue.get("title", "")
)
except Exception:
pass
return {"ok": True, "suggested_branch": suggested, **links}
_BRANCH_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
async def _sync_kanban_label(
owner: str, repo: str, number: int, column: str, client=None
) -> None:
"""Mirror a column on the issue's kanban:* label (best effort)."""
if not get_settings().sync_labels:
return
client = client or gitea
try:
desired = (
f"kanban:{column}" if column in LABELLED_COLUMNS else None
)
issue = await client.get_issue(owner, repo, number)
current = [
label for label in issue.get("labels") or []
if str(label.get("name", "")).startswith("kanban:")
]
for label in current:
if label.get("name") != desired:
await client.remove_issue_label(
owner, repo, number, label["id"]
)
if desired and all(l.get("name") != desired for l in current):
label = await client.ensure_label(
owner, repo, desired, COLUMN_COLORS[column]
)
await client.add_issue_label(owner, repo, number, label["id"])
except Exception as exc:
logger.warning(
"Label sync failed for %s/%s#%s: %s", owner, repo, number, exc
)
@app.post("/api/issues/{owner}/{repo}/{number}/branch")
async def api_create_branch(
owner: str,
repo: str,
number: int,
request: Request,
name: str = Form(""),
csrf_token: str = Form(""),
):
"""Create the card's branch in Gitea (issue #3), then start the card.
Default name: issue-<n>-<slug of the title> — the convention the
merged-PR automation (issue #2) recognizes. Creating the branch
moves the card to In Progress and journals it.
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
try:
issue = await client.get_issue(owner, repo, number)
except Exception:
return JSONResponse(
{"ok": False, "error": "Issue introuvable"}, status_code=404
)
if not _is_issue(issue):
return JSONResponse(
{"ok": False, "error": "Pas une issue"}, status_code=400
)
branch = (name or "").strip() or code_links_service.branch_name_for(
number, issue.get("title", "")
)
if (
not _BRANCH_NAME_RE.match(branch)
or ".." in branch
or branch.endswith("/")
or branch.endswith(".lock")
):
return JSONResponse(
{"ok": False, "error": f"Nom de branche invalide : {branch}"},
status_code=400,
)
repo_info = {}
try:
repo_info = await client.get_repo(owner, repo)
except Exception:
pass
base_url = repo_info.get("html_url", "")
if base_url and client.kind == "github":
branch_url = f"{base_url}/tree/{branch}"
elif base_url:
branch_url = f"{base_url}/src/branch/{branch}"
else:
branch_url = ""
existing = await _safe_list_branches(owner, repo, client)
if branch in existing:
return {
"ok": True, "branch": branch, "exists": True,
"url": branch_url, "moved": False,
}
default_branch = repo_info.get("default_branch") or "main"
try:
await client.create_branch(owner, repo, branch, default_branch)
except Exception as exc:
return JSONResponse(
{"ok": False, "error": f"Création impossible : {exc}"},
status_code=502,
)
client.invalidate_cache()
full_name = f"{owner}/{repo}"
moved = False
if issue.get("state") == "open":
board = get_board_state(
full_name, workspace_id=ws_id, connection_id=conn_id
)
current = next(
(
col for col, cards in board.items()
if any(c["issue_number"] == number for c in cards)
),
"backlog",
)
if current != "in_progress":
move_issue(
full_name, number, "in_progress",
workspace_id=ws_id, connection_id=conn_id,
)
log_event(
full_name, number, "moved",
from_column=current, to_column="in_progress",
source="branch",
workspace_id=ws_id, connection_id=conn_id,
)
await _sync_kanban_label(
owner, repo, number, "in_progress", client
)
moved = True
await _publish_board_changed(full_name)
return {
"ok": True, "branch": branch, "from": default_branch,
"url": branch_url, "moved": moved,
}
async def _safe_list_branches(
owner: str, repo: str, client=None
) -> list[str]:
client = client or gitea
try:
return [
b.get("name", "") for b in await client.list_branches(owner, repo)
]
except Exception:
return []
@app.get("/api/issues/{owner}/{repo}/{number}")
async def api_issue_detail(
request: Request, owner: str, repo: str, number: int
):
"""Issue detail + comments for the card panel (proxied from the forge)."""
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
try:
issue = await client.get_issue(owner, repo, number)
comments = await client.get_issue_comments(owner, repo, number)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return JSONResponse(
{"ok": False, "error": "Issue not found"}, status_code=404
)
raise
return {
"ok": True,
"issue": _issue_payload(issue),
"comments": [_comment_payload(c) for c in comments],
}
@app.post("/api/issues/{owner}/{repo}/{number}/comments")
async def api_issue_comment(
request: Request,
owner: str,
repo: str,
number: int,
body: str = Form(...),
csrf_token: str = Form(""),
):
"""Post a comment on the issue in Gitea."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
body = body.strip()
if not body:
return JSONResponse(
{"ok": False, "error": "Comment must not be empty"}, status_code=400
)
if len(body) > 10_000:
return JSONResponse(
{"ok": False, "error": "Comment too large (max 10000 chars)"},
status_code=413,
)
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
comment = await client.create_comment(owner, repo, number, body)
log_event(
f"{owner}/{repo}", number, "commented",
workspace_id=ws_id, connection_id=conn_id,
)
return JSONResponse(
{"ok": True, "comment": _comment_payload(comment)}, status_code=201
)
@app.get("/api/events")
async def api_events(repo: str = "", limit: int = 100):
"""Latest journal entries (all repos, or one via ?repo=owner/name)."""
events = get_events(repo or None, max(1, min(limit, 500)))
return {"ok": True, "events": events}
@app.get("/api/metrics")
async def api_metrics(repo: str = "", window: int = 30):
"""Throughput / cycle-time metrics from the journal."""
metrics = get_metrics(repo or None, window)
weekly = forecast_service.weekly_closures(
get_events(repo_full_name=repo or None, limit=2000)
)
metrics["weekly_closures"] = [b["count"] for b in weekly]
stats = forecast_service.throughput_stats(weekly)
metrics["throughput_4w"] = stats
return {"ok": True, "metrics": metrics}
def _repo_forecast_label(repo_full_name: str, remaining: int) -> str | None:
"""Backlog burn-down ETA label for one repo (journal-based)."""
weekly = forecast_service.weekly_closures(
get_events(repo_full_name=repo_full_name, limit=2000)
)
est = forecast_service.estimate(remaining, weekly)
if est is None:
return None
if est.get("done"):
return "✅ rien d'ouvert"
label = forecast_service.eta_label(est)
return f"backlog épuisé {label}" if label else None
# ── Label management ───────────────────────────────────────
@app.get("/api/repos/{owner}/{repo}/labels")
async def api_list_labels(request: Request, owner: str, repo: str):
"""All label definitions of a repo."""
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
labels = await client.get_labels(owner, repo)
return {
"ok": True,
"labels": [
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
for l in labels
],
}
@app.post("/api/repos/{owner}/{repo}/labels")
async def api_create_label(
request: Request,
owner: str,
repo: str,
name: str = Form(...),
color: str = Form("7c5cfc"),
csrf_token: str = Form(""),
):
"""Create a label definition in the repo."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
name = name.strip()
color = color.strip().lstrip("#")
if not name or len(name) > 50:
return JSONResponse(
{"ok": False, "error": "Label name must be 1-50 chars"},
status_code=400,
)
if len(color) != 6 or any(c not in "0123456789abcdefABCDEF" for c in color):
return JSONResponse(
{"ok": False, "error": "Color must be a 6-digit hex value"},
status_code=400,
)
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
label = await client.create_label(owner, repo, name, color)
client.forget_label_cache(owner, repo)
return JSONResponse(
{
"ok": True,
"label": {
"id": label.get("id"),
"name": label.get("name", name),
"color": label.get("color", color),
},
},
status_code=201,
)
@app.delete("/api/repos/{owner}/{repo}/labels/{label_id}")
async def api_delete_label(
request: Request, owner: str, repo: str, label_id: str
):
"""Delete a label definition (CSRF via the X-CSRF-Token header).
label_id is the Gitea numeric id, or the label NAME on
GitHub (0.24.0) — hence a string here.
"""
if not _csrf_valid(request, ""):
return _csrf_error()
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
await client.delete_label(owner, repo, label_id)
return {"ok": True, "deleted": label_id}
@app.post("/api/issues/{owner}/{repo}/{number}/labels")
async def api_toggle_issue_label(
request: Request,
owner: str,
repo: str,
number: int,
label_id: str = Form(...),
action: str = Form(...),
csrf_token: str = Form(""),
):
"""Add or remove one label on one issue; returns the new label set."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
client, _cid, _ws = await _resolve_board_scope(request, owner, repo)
if client.kind == "gitea" and str(label_id).isdigit():
label_id = int(label_id) # Gitea veut des ids numériques
if action not in ("add", "remove"):
return JSONResponse(
{"ok": False, "error": "action must be 'add' or 'remove'"},
status_code=400,
)
if action == "add":
await client.add_issue_label(owner, repo, number, label_id)
else:
await client.remove_issue_label(owner, repo, number, label_id)
await _publish_board_changed(f"{owner}/{repo}")
issue = await client.get_issue(owner, repo, number)
return {
"ok": True,
"labels": [
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
for l in issue.get("labels") or []
],
}
# ── MCP server (read-only tools over JSON-RPC) ─────────────
MCP_TOOLS = [
{
"name": "list_projects",
"description": "List the projects (Gitea repos) Kanbante tracks, grouped by activity.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "get_board",
"description": "Get the kanban board of one repo: columns with their cards.",
"inputSchema": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
},
"required": ["owner", "repo"],
},
},
{
"name": "get_issue",
"description": "Get one issue with its body and comments.",
"inputSchema": {
"type": "object",
"properties": {
"owner": {"type": "string"},
"repo": {"type": "string"},
"number": {"type": "integer"},
},
"required": ["owner", "repo", "number"],
},
},
{
"name": "get_focus",
"description": "Cards in progress or in review across all active projects.",
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "search_issues",
"description": "Search issues by text across all repos.",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"state": {"type": "string", "enum": ["open", "closed", "all"]},
},
"required": ["query"],
},
},
{
"name": "get_activity",
"description": "Latest entries of the board event journal.",
"inputSchema": {
"type": "object",
"properties": {"limit": {"type": "integer"}},
},
},
{
"name": "get_metrics",
"description": "Throughput and cycle-time metrics from the journal.",
"inputSchema": {
"type": "object",
"properties": {"repo": {"type": "string"}},
},
},
]
async def _mcp_call_tool(name: str, args: dict) -> str:
"""Execute one read-only MCP tool and return text content."""
if name == "list_projects":
repos = await gitea.search_repos()
active, inactive, archived = classify_repos(repos)
lines = [f"Active projects ({len(active)}):"]
lines += [
f"- {r['full_name']} ({r.get('open_issues_count') or 0} open issues)"
for r in active
]
lines.append(
f"Inactive: {len(inactive)} - Archived: {len(archived)}"
)
return "\n".join(lines)
if name == "get_board":
owner, repo = args["owner"], args["repo"]
items = await gitea.get_repo_issues(owner, repo, state="all")
issues = [i for i in items if _is_issue(i)]
open_issues = [i for i in issues if i.get("state") == "open"]
columns = _merge_issues_with_board(open_issues, get_board_state(f"{owner}/{repo}"))
return json.dumps(
{
column: [
{
"number": i["number"],
"title": i["title"],
"labels": [l.get("name") for l in i.get("labels") or []],
}
for i in cards
]
for column, cards in columns.items()
},
ensure_ascii=False,
)
if name == "get_issue":
detail = await api_issue_detail(args["owner"], args["repo"], int(args["number"]))
return json.dumps(detail, ensure_ascii=False, default=str)
if name == "get_focus":
groups = await _collect_focus_groups()
return json.dumps(
[
{
"repo": g["full_name"],
"cards": [
{
"number": c["issue"]["number"],
"title": c["issue"]["title"],
"column": c["column"],
}
for c in g["cards"]
],
}
for g in groups
],
ensure_ascii=False,
)
if name == "search_issues":
results = await gitea.search_issues(
q=args["query"], state=args.get("state", "open")
)
return json.dumps(
[
{
"repo": (i.get("repository") or {}).get("full_name"),
"number": i["number"],
"title": i["title"],
"state": i["state"],
}
for i in results
],
ensure_ascii=False,
)
if name == "get_activity":
return json.dumps(
get_events(limit=int(args.get("limit", 50))), ensure_ascii=False
)
if name == "get_metrics":
return json.dumps(get_metrics(args.get("repo") or None), ensure_ascii=False)
raise ValueError(f"Unknown tool: {name}")
@app.get("/mcp")
async def mcp_info():
"""Discoverability for the MCP endpoint (the protocol is POST)."""
return {
"name": "kanbante",
"version": APP_VERSION,
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": True,
"tools": [t["name"] for t in MCP_TOOLS],
}
@app.post("/mcp")
async def mcp_endpoint(request: Request):
"""Minimal MCP server: initialize / tools/list / tools/call / ping.
Read-only by design (Bruno's rule: cross-platform reads first,
writes go through approval flows elsewhere).
"""
try:
payload = await request.json()
except ValueError:
return JSONResponse(
{"jsonrpc": "2.0", "id": None,
"error": {"code": -32700, "message": "Parse error"}},
status_code=400,
)
if isinstance(payload, list): # batch: not supported by this server
return JSONResponse(
{"jsonrpc": "2.0", "id": None,
"error": {"code": -32600, "message": "Batch requests not supported"}},
status_code=400,
)
method = payload.get("method", "")
rpc_id = payload.get("id")
if rpc_id is None: # notification: acknowledge silently
return Response(status_code=202)
params = payload.get("params") or {}
def _result(result):
return {"jsonrpc": "2.0", "id": rpc_id, "result": result}
def _error(code, message):
return {"jsonrpc": "2.0", "id": rpc_id,
"error": {"code": code, "message": message}}
if method == "initialize":
return _result(
{
"protocolVersion": params.get("protocolVersion", "2024-11-05"),
"capabilities": {"tools": {}},
"serverInfo": {"name": "kanbante", "version": APP_VERSION},
}
)
if method == "ping":
return _result({})
if method == "tools/list":
return _result({"tools": MCP_TOOLS})
if method == "tools/call":
tool_name = params.get("name", "")
arguments = params.get("arguments") or {}
try:
text = await _mcp_call_tool(tool_name, arguments)
except KeyError as exc:
return _result(
{"content": [{"type": "text", "text": f"Missing argument: {exc}"}],
"isError": True}
)
except Exception as exc:
return _result(
{"content": [{"type": "text", "text": f"Tool error: {exc}"}],
"isError": True}
)
return _result({"content": [{"type": "text", "text": text}], "isError": False})
return _error(-32601, f"Method not found: {method}")
@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,
)
parts = _split_full_name(repo_full_name)
if parts is not None:
_client, conn_id, ws_id = await _resolve_board_scope(
request, parts[0], parts[1]
)
set_notes(
repo_full_name, note,
workspace_id=ws_id, connection_id=conn_id,
)
else:
set_notes(repo_full_name, note)
return {"ok": True}
@app.get("/api/stats")
async def api_stats():
"""Get project stats."""
return get_stats()
@app.get("/api/repos")
async def api_repos():
"""Every repo, for pickers (capture page repo selector, 0.21.0).
Unlike /api/palette (capped at 60 for a launcher), a picker
must be exhaustive — Bruno has 130+ repos. Ranked for the
empty-query case: starred first, archived last, most
recently updated first in between.
"""
repos = await gitea.search_repos()
active, inactive, archived = classify_repos(repos)
try:
starred = {r["full_name"] for r in await gitea.list_starred()}
except Exception:
starred = set()
out = [
{
"full_name": r["full_name"],
"starred": r["full_name"] in starred,
"updated_at": r.get("updated_at") or "",
"archived": bool(r.get("archived")),
}
for r in active + inactive + archived
]
out.sort(key=lambda x: x["updated_at"], reverse=True)
out.sort(key=lambda x: (not x["starred"], x["archived"]))
return {"repos": out}
@app.get("/api/palette")
async def api_palette(q: str = ""):
"""Data for the Ctrl+K command palette (roadmap #10).
Projects are always returned (the palette filters them locally);
issues are searched globally once the query has 2+ characters,
annotated with their current board column.
"""
repos = await gitea.search_repos()
active, inactive, _archived = classify_repos(repos)
projects = [
{"full_name": r["full_name"]} for r in (active + inactive)[:60]
]
issues: list[dict] = []
query = q.strip()
if len(query) >= 2:
try:
found = await gitea.search_issues(query, state="all")
except Exception as exc:
logger.debug("palette search failed: %s", exc)
found = []
placements: dict[str, dict[int, str]] = {}
for item in found[:40]:
if not _is_issue(item):
continue
full = (item.get("repository") or {}).get("full_name", "")
if not full:
continue
if full not in placements:
placements[full] = {
card["issue_number"]: col
for col, cards in get_board_state(full).items()
for card in cards
}
if item.get("state") == "closed":
column = "done"
else:
column = placements[full].get(item["number"], "backlog")
issues.append(
{
"repo": full,
"number": item["number"],
"title": item.get("title", ""),
"state": item.get("state", "open"),
"column": column,
"column_label": COLUMN_LABELS[column],
}
)
if len(issues) >= 8:
break
return {"projects": projects, "issues": issues}
@app.get("/api/projects")
async def api_projects():
"""Get all projects (JSON), classified like the dashboard."""
repos = await gitea.search_repos()
active, inactive, archived = classify_repos(repos)
return {"active": active, "inactive": inactive, "archived": archived}
@app.get("/api/health")
async def health():
"""Liveness probe: the process is up. Deliberately dependency-free."""
return {"status": "ok", "version": APP_VERSION}
@app.get("/api/readiness")
async def readiness():
"""Readiness probe: database writable and Gitea reachable."""
db_ok = ping_db()
gitea_ok = await gitea.ping()
ready = db_ok and gitea_ok
return JSONResponse(
{
"status": "ready" if ready else "not_ready",
"version": APP_VERSION,
"checks": {"database": db_ok, "gitea": gitea_ok},
},
status_code=200 if ready else 503,
)