5843 lines
203 KiB
Python
5843 lines
203 KiB
Python
"""
|
||
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 _client_for_repo(repo_full_name: str):
|
||
"""(client, connection_id) holding this repo, default first.
|
||
|
||
Board-less callers (capture, serveur MCP) have no request /
|
||
workspace scope: the connection holding the repo among the
|
||
global ones wins, so a card can target a GitHub project too
|
||
(0.24.0). Falls back to the historical client.
|
||
"""
|
||
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:
|
||
return candidate, candidate.connection_id
|
||
return gitea, registry.default_id
|
||
|
||
|
||
async def _do_capture(
|
||
title: str,
|
||
body: str,
|
||
repo_full_name: str,
|
||
column: str = "backlog",
|
||
source: str = "capture",
|
||
) -> dict:
|
||
"""Create a Gitea issue from a capture and place its card.
|
||
|
||
Shared by POST /api/capture, the /capture page, the ntfy
|
||
listener and the MCP create_issue tool (0.26.0, source="mcp").
|
||
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
|
||
client, conn_id = await _client_for_repo(repo_full_name)
|
||
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=source,
|
||
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 (tools over JSON-RPC) ───────────────────────
|
||
# Lectures depuis la 0.5.0 ; écritures minimales (create_issue,
|
||
# add_comment) depuis la 0.26.0, gardées par la même authentification
|
||
# que tout /mcp (session, Basic ou jeton d'API kbt_ — voir le
|
||
# middleware : un jeton n'ouvre jamais l'interface web).
|
||
|
||
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"}},
|
||
},
|
||
},
|
||
{
|
||
"name": "create_issue",
|
||
"description": "Create an issue (card) in a repo and place it on "
|
||
"the board (backlog by default). Write tool — same /mcp "
|
||
"authentication as the reads (kbt_ API token, session or Basic).",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"owner": {"type": "string"},
|
||
"repo": {"type": "string"},
|
||
"title": {"type": "string"},
|
||
"body": {"type": "string"},
|
||
"column": {"type": "string", "enum": list(KANBAN_COLUMNS)},
|
||
},
|
||
"required": ["owner", "repo", "title"],
|
||
},
|
||
},
|
||
{
|
||
"name": "add_comment",
|
||
"description": "Add a comment to an existing issue (card). "
|
||
"Write tool — same /mcp authentication as the reads "
|
||
"(kbt_ API token, session or Basic).",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"owner": {"type": "string"},
|
||
"repo": {"type": "string"},
|
||
"number": {"type": "integer"},
|
||
"body": {"type": "string"},
|
||
},
|
||
"required": ["owner", "repo", "number", "body"],
|
||
},
|
||
},
|
||
]
|
||
|
||
|
||
async def _mcp_call_tool(name: str, args: dict) -> str:
|
||
"""Execute one MCP tool and return text content.
|
||
|
||
Reads hit the forge's read API; the two write tools
|
||
(create_issue, add_comment) reuse the existing creation /
|
||
comment services — never a reimplemented forge call.
|
||
"""
|
||
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)
|
||
if name == "create_issue":
|
||
owner, repo = args["owner"], args["repo"]
|
||
created = await _do_capture(
|
||
str(args["title"]),
|
||
str(args.get("body") or ""),
|
||
f"{owner}/{repo}",
|
||
str(args.get("column") or "backlog"),
|
||
source="mcp",
|
||
)
|
||
return json.dumps(created, ensure_ascii=False)
|
||
if name == "add_comment":
|
||
owner, repo = args["owner"], args["repo"]
|
||
number = int(args["number"])
|
||
body = str(args["body"] or "").strip()
|
||
if not body:
|
||
raise ValueError("Comment must not be empty")
|
||
if len(body) > 10_000:
|
||
raise ValueError("Comment too large (max 10000 chars)")
|
||
full_name = f"{owner}/{repo}"
|
||
client, conn_id = await _client_for_repo(full_name)
|
||
comment = await client.create_comment(owner, repo, number, body)
|
||
ws_id = next(
|
||
(
|
||
ws["id"]
|
||
for ws in models.list_workspaces()
|
||
if conn_id in models.workspace_connection_ids(ws["id"])
|
||
),
|
||
1,
|
||
)
|
||
log_event(
|
||
full_name, number, "commented", source="mcp",
|
||
workspace_id=ws_id, connection_id=conn_id,
|
||
)
|
||
return json.dumps(
|
||
{
|
||
"id": comment.get("id"),
|
||
"body": comment.get("body", body),
|
||
"html_url": comment.get("html_url", ""),
|
||
"repo_full_name": full_name,
|
||
"number": number,
|
||
},
|
||
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": False,
|
||
"write_tools": ["create_issue", "add_comment"],
|
||
"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.
|
||
|
||
Reads since 0.5.0; since 0.26.0, two minimal write tools
|
||
(create_issue, add_comment) for the agent service, guarded by
|
||
the same /mcp authentication as the reads (session, Basic or
|
||
kbt_ API token — a token never unlocks the web UI).
|
||
"""
|
||
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,
|
||
)
|