From d51ef5a970ffdb0af1ee2b930c0efeacbb1f191d Mon Sep 17 00:00:00 2001 From: Spark Date: Wed, 21 Oct 2026 14:48:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(0.24.0):=20utilisateurs,=20administration,?= =?UTF-8?q?=20espaces,=20param=C3=A8tres,=20archives=20(P2-P4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Comptes en base (PBKDF2), rôles, désactivation qui tue la session, audit ; secours environnement pour l'opérateur ; liaison draco par utilisateur ; page /profile. - Espaces de travail : connexions, membres, sélection de projets ; board/notes/épingles/journal scopés par espace ; sélecteur d'espace, badge connexion. - Panneaux /admin (utilisateurs, connexions, espaces, paramètres — l'environnement amorce, le panneau gouverne). - /archives : archive locale réversible, archivage forge confirmé, purge de l'état local, export Markdown, file « à archiver ? » avec archivage en lot. - Correctif session : les comptes membres résolus par la base (la session n'est plus limitée à l'opérateur d'environnement). - 231 tests verts (12 nouveaux au palier 19). --- CHANGELOG.md | 58 + app/digest.py | 2 +- app/main.py | 2311 +++++++++++++++++++++++--- app/templates/_header.html | 27 +- app/templates/admin.html | 103 ++ app/templates/admin_connections.html | 78 + app/templates/admin_settings.html | 43 + app/templates/admin_workspaces.html | 115 ++ app/templates/archives.html | 139 ++ app/templates/dashboard.html | 2 + app/templates/profile.html | 95 ++ static/css/style.css | 25 + tests/test_palier12.py | 2 +- tests/test_palier19.py | 360 ++++ 14 files changed, 3131 insertions(+), 229 deletions(-) create mode 100644 app/templates/admin.html create mode 100644 app/templates/admin_connections.html create mode 100644 app/templates/admin_settings.html create mode 100644 app/templates/admin_workspaces.html create mode 100644 app/templates/archives.html create mode 100644 app/templates/profile.html create mode 100644 tests/test_palier19.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3312eb8..59f0a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +## 0.24.0 — 2026-10-21 + +### Ajouté — multi-utilisateurs, multi-forges, espaces, archives + +Réalise l'analyse du 2026-10-21 (décisions D1 à D6 appliquées +selon les recommandations). Aucune configuration obligatoire : +au premier démarrage, la connexion Gitea historique est créée +depuis l'environnement, l'espace **Principal** la contient, et +le compte d'environnement devient l'administrateur initial +(mot de passe haché en base). Migration automatique des bases +existantes (portées (1, 1) partout). + +- **Connexions (P0/P1)** — une forge = une connexion (Gitea ou + **GitHub**, nouveau client complet : dépôts, issues, étiquettes + par nom, jalons, PR, archivage). Jeton **chiffré** (Fernet) en + base, jamais réaffiché (masque ••••). Registre de clients, + instantanés par connexion. Webhooks par connexion + (`/webhooks/{slug}`, signatures Gitea et GitHub) en plus du + webhook historique. +- **Utilisateurs (P2)** — comptes en base (PBKDF2), rôles + admin/membre, activation/désactivation (une session d'un + compte désactivé meurt aussitôt), journal d'audit admin. + Connexion par la base ; les identifiants d'environnement + restent un **secours** pour le compte opérateur. Liaison + **draco par utilisateur** (le pont SSO lie et ouvre la + session du bon compte ; sémantique mono-utilisateur 0.23 + conservée tant qu'il n'y a qu'un compte). Page **/profile** : + infos, mot de passe, déliaison draco, connexions personnelles. +- **Espaces de travail (P3)** — l'unité de présentation : + connexions attachées, membres (admin d'espace / membre / + lecteur), sélection explicite de projets optionnelle. + **Board, notes, épingles du jour, journal : scopés par + espace** (le même projet peut être rangé différemment dans + deux espaces). Sélecteur d'espace dans l'en-tête (visible + dès qu'on a accès à plusieurs espaces), badge de connexion + sur les projets quand l'espace en a plusieurs. Panneau + **Paramètres** : l'environnement amorce, le panneau + gouverne — surcharges en base pour les réglages courants + (fenêtre d'activité, TTL, seuils zombies/âge, horaires des + rituels, seuil d'archivage…), provenance affichée. +- **Archives (P4)** — page **/archives** : archive **locale** + (masque le projet dans l'espace, forge intacte, réversible), + archivage **sur la forge** (confirmé, journalisé), purge de + l'état local de l'espace, export du récapitulatif Markdown, + file **« À archiver ? »** (inactifs depuis + `archive_suggest_days`, 180 j par défaut) avec archivage + local en lot. Les archives sortent du tableau de bord ; + l'onglet Archivés y gagne un lien « Gérer ». + +### Limites assumées de cette version + +- Les rituels d'arrière-plan (briefing, digest, règles, + serveur MCP) travaillent sur la portée historique + (connexion principale, espace Principal). +- `KANBANTE_MASTER_KEY` (optionnelle) : sans elle, une clé de + chiffrement est générée une fois et gardée en base. + + ## 0.23.3 — 2026-10-21 ### Correctifs — barre commune v0.1.4 (retours de Bruno) diff --git a/app/digest.py b/app/digest.py index 193e66b..17241e5 100644 --- a/app/digest.py +++ b/app/digest.py @@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None: { "protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {}, - "clientInfo": {"name": "kanbante", "version": "0.22.1"}, + "clientInfo": {"name": "kanbante", "version": "0.24.0"}, }, req_id=1, ) diff --git a/app/main.py b/app/main.py index 6383356..fae550d 100644 --- a/app/main.py +++ b/app/main.py @@ -50,6 +50,7 @@ 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, @@ -57,6 +58,7 @@ from app.briefing import ( send_ntfy, ) from app.capture import ntfy_capture_loop, parse_capture_text +from app import models from app.models import ( KANBAN_COLUMNS, add_pick, @@ -164,12 +166,31 @@ async def lifespan(app: FastAPI): logger.warning( "GITEA_TOKEN is not set: only public Gitea content will be visible." ) - # Chantier B: hydrate the client cache from persisted + # 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 Gitea when data exists. - gitea.hydrate_from_snapshots() + # 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(gitea, classify_repos) + snapshots_service.snapshot_loop( + registry.global_clients, classify_repos + ) ) scheduler = asyncio.create_task(_briefing_scheduler()) digest_task = asyncio.create_task(_digest_scheduler()) @@ -207,6 +228,7 @@ async def lifespan(app: FastAPI): 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) ────── @@ -234,13 +256,14 @@ def _session_secret() -> bytes: 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 - payload = f"{user}.{exp}" sig = hmac.new( - _session_secret(), payload.encode("utf-8"), hashlib.sha256 + _session_secret(), f"{user}.{exp}".encode("utf-8"), hashlib.sha256 ).hexdigest() - return f"{payload}.{sig}" + return f"{user}.{exp}.{sig}" def _session_user(request: Request) -> str | None: @@ -259,9 +282,15 @@ def _session_user(request: Request) -> str | None: return None except ValueError: return None - if not secrets.compare_digest(user, get_settings().auth_user): - return None - return user + # 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: @@ -279,12 +308,44 @@ def _basic_ok(request: Request) -> bool: ) and secrets.compare_digest(password, settings.auth_password) +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/"): + 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: @@ -299,7 +360,12 @@ class AuthMiddleware(BaseHTTPMiddleware): status_code=503, ) return await call_next(request) - if _session_user(request) is not None or _basic_ok(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) # API consumers get a plain 401 (no browser prompt, no # redirect they cannot follow); browsers get the login page. @@ -349,14 +415,43 @@ async def login_submit( request, "login.html", status_code=403, next=_safe_next(next), error="Session expirée — réessaie.", ) - ok = secrets.compare_digest( - username, settings.auth_user - ) and secrets.compare_digest(password, settings.auth_password) + 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, @@ -380,27 +475,1241 @@ 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) : si le cookie partagé est valide, la session native - de l'opérateur configuré est créée (Kanbante est mono-utilisateur, - aucun mapping de comptes n'est nécessaire) ; sinon, retour à - l'écran de connexion. Inerte sans ``DRACO_SESSION_SECRET``. + (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() - if not settings.auth_enabled or draco.claims_for(request) is None: + claims = draco.claims_for(request) + if not settings.auth_enabled or claims is None: return RedirectResponse("/login", status_code=303) - 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", + 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, + ) + + +@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) + 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, + msg=msg, err=err, + ) + + +@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: @@ -468,6 +1777,11 @@ _NAV_BY_TEMPLATE = { "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", } @@ -493,6 +1807,26 @@ def render( 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: @@ -619,7 +1953,11 @@ def _merge_issues_with_board( def _adopt_label_columns( - full_name: str, open_issues: list[dict], board_state: dict + 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. @@ -648,10 +1986,14 @@ def _adopt_label_columns( continue current = placement.get(issue["number"], "backlog") if current != target: - move_issue(full_name, issue["number"], 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 @@ -672,9 +2014,102 @@ def _parse_order(raw: str) -> list[int]: 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) -> dict | None: +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 @@ -682,8 +2117,9 @@ async def _next_milestone(owner: str, repo: str) -> dict | None: 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 gitea.get_milestones(owner, repo, state="open") + 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 @@ -759,34 +2195,53 @@ def _health_dot(health: dict) -> dict: @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request): - """Main dashboard — all projects at a glance.""" - repos = await gitea.search_repos() + """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) - try: - starred_names = { - r.get("full_name") for r in await gitea.list_starred() - } - except Exception as exc: # stars are decoration, never fatal (#20) - logger.debug("starred list failed: %s", exc) - starred_names = set() + 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) + repo_info["next_milestone"] = await _next_milestone( + owner, repo, client + ) try: - health = await _project_health(owner, repo) + 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"] = full_name in starred_names + 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["full_name"] in starred_names + 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: @@ -826,6 +2281,7 @@ async def dashboard(request: Request): 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, ) @@ -837,11 +2293,12 @@ async def api_project_star( starred: str = Form(...), csrf_token: str = Form(""), ): - """Pin / unpin a project via its Gitea star (issue #20).""" + """Pin / unpin a project via its forge star (issue #20).""" if not _csrf_valid(request, csrf_token): return _csrf_error() want = starred == "1" - await gitea.set_starred(owner, repo, want) + client, _cid, _ws = await _resolve_board_scope(request, owner, repo) + await client.set_starred(owner, repo, want) return JSONResponse({"ok": True, "starred": want}) @@ -849,25 +2306,42 @@ async def api_project_star( 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}" - all_items = await gitea.get_repo_issues(owner, repo, state="all") + 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 Gitea -> done, deleted -> purged. - changes = reconcile_board_state(full_name, open_numbers, closed_numbers) + # 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") + 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)) + _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).get("done", []) + c["issue_number"] + for c in get_board_state( + full_name, workspace_id=ws_id, connection_id=conn_id + ).get("done", []) } closed_display = [ i @@ -883,7 +2357,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""): milestone_forecast = None display_issues = open_issues + closed_display try: - milestones = await gitea.get_milestones(owner, repo) + 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 = [] @@ -931,9 +2405,11 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""): if due_date and est["central"] > due_date.date(): milestone_forecast += " — ⚠️ après l'échéance" - board_state = get_board_state(full_name) + 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) + await _annotate_code(owner, repo, columns, client) labels: dict[str, dict] = {} for issue in open_issues: @@ -942,7 +2418,7 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""): repo_info = {} try: - repo_info = await gitea.get_repo(owner, repo) + 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) @@ -957,9 +2433,14 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""): column_labels=COLUMN_LABELS, column_colors=COLUMN_COLORS, labels=sorted(labels.values(), key=lambda l: l["name"].lower()), - notes=get_notes(full_name) or "", + 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), + 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, @@ -971,7 +2452,12 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""): def _board_columns_for( - owner: str, repo: str, all_items: list[dict], milestone: str = "" + 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).""" @@ -980,11 +2466,25 @@ def _board_columns_for( 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) + 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") - _adopt_label_columns(full_name, open_issues, get_board_state(full_name)) - board_state = get_board_state(full_name) + 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. @@ -1007,7 +2507,9 @@ def _board_columns_for( @app.get("/api/board/{owner}/{repo}") -async def api_board(owner: str, repo: str, milestone: str = ""): +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 @@ -1015,13 +2517,18 @@ async def api_board(owner: str, repo: str, milestone: str = ""): page, so label adoptions and closures are reflected identically. """ full_name = f"{owner}/{repo}" - all_items = await gitea.get_repo_issues(owner, repo, state="all") + 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 + owner, repo, all_items, milestone, + workspace_id=ws_id, connection_id=conn_id, ) - await _annotate_code(owner, repo, columns) + await _annotate_code(owner, repo, columns, client) settings = get_settings() - ages = _card_ages(full_name, display_issues, board_state) + 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: @@ -1041,7 +2548,11 @@ async def api_board(owner: str, repo: str, milestone: str = ""): def _card_ages( - full_name: str, open_issues: list[dict], board: dict[str, list[dict]] + 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. @@ -1049,7 +2560,9 @@ def _card_ages( board_state.updated_at (pre-journal data), the issue's own creation date. """ - entered = column_entry_times(full_name) + 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() @@ -1069,16 +2582,27 @@ def _card_ages( return ages -async def _collect_focus_groups() -> list[dict]: - """In-progress / in-review cards across all active repos.""" - repos = await gitea.search_repos() +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("/") - items = await gitea.get_repo_issues(owner, name, state="open") - return repo["full_name"], [i for i in items if _is_issue(i)] + 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 @@ -1088,16 +2612,31 @@ async def _collect_focus_groups() -> list[dict]: if isinstance(result, Exception): logger.warning("focus: repo fetch failed: %s", result) continue - full_name, open_issues = result - _adopt_label_columns(full_name, open_issues, get_board_state(full_name)) - columns = _merge_issues_with_board(open_issues, get_board_state(full_name)) + 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}) + groups.append( + {"full_name": full_name, "cards": cards, "conn_id": cid} + ) return groups @@ -1112,14 +2651,15 @@ async def api_assign_self( """Assign the token's user to an issue (triage key A, issue #11).""" if not _csrf_valid(request, csrf_token): return _csrf_error() - user = await gitea.get_user() + 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 gitea.get_issue(owner, repo, number) + issue = await client.get_issue(owner, repo, number) current = [ a.get("login", "") for a in (issue.get("assignees") or []) @@ -1130,7 +2670,7 @@ async def api_assign_self( current.append(issue["assignee"]["login"]) if login not in current: current.append(login) - await gitea.set_issue_assignees(owner, repo, number, current) + await client.set_issue_assignees(owner, repo, number, current) await _publish_board_changed(f"{owner}/{repo}") return {"ok": True, "assignees": current} @@ -1145,13 +2685,14 @@ def _excerpt(body: str | None, limit: int = 280) -> str: @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 gitea.get_repo(owner, repo) + repo_info = await client.get_repo(owner, repo) try: - raw_milestones = await gitea.get_milestones(owner, repo, state="open") + 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 = [] @@ -1207,7 +2748,7 @@ async def project_page(request: Request, owner: str, repo: str): health=health, health_dot=health_dot, events=events, - notes=get_notes(full_name), + 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, @@ -1217,13 +2758,20 @@ async def project_page(request: Request, owner: str, repo: str): @app.get("/triage", response_class=HTMLResponse) async def triage_picker(request: Request): """Triage mode (issue #11): pick a project by its backlog size.""" - repos = await gitea.search_repos() + 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("/") - all_items = await gitea.get_repo_issues(owner, name, state="all") - columns, _display, _state = _board_columns_for(owner, name, all_items) + 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} @@ -1239,11 +2787,16 @@ async def triage_picker(request: Request): 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}" - all_items = await gitea.get_repo_issues(owner, repo, state="all") + 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 + 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, ) - ages = _card_ages(full_name, display_issues, board_state) queue = [ { "number": issue["number"], @@ -1276,11 +2829,15 @@ 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) -> dict | None: +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 gitea.get_issue(owner, name, pick["issue_number"]) + issue = await client.get_issue(owner, name, pick["issue_number"]) except Exception: return None if issue.get("state") == "closed": @@ -1288,7 +2845,10 @@ async def _enrich_pick(pick: dict) -> dict | None: else: placement = { card["issue_number"]: col - for col, cards in get_board_state(pick["repo_full_name"]).items() + 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") @@ -1318,11 +2878,18 @@ async def api_today_pin( {"ok": False, "error": "Invalid repo_full_name"}, status_code=400 ) today = _today_str() - picks = get_picks(today) - if any( - p["repo_full_name"] == repo_full_name and p["issue_number"] == issue_number - for p in picks - ): + 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( @@ -1333,7 +2900,10 @@ async def api_today_pin( }, status_code=400, ) - add_pick(today, repo_full_name, issue_number) + add_pick( + today, repo_full_name, issue_number, + workspace_id=ws_id, connection_id=conn_id, + ) return {"ok": True, "count": len(picks) + 1} @@ -1347,32 +2917,45 @@ async def api_today_unpin( """Remove a card from today's focus.""" if not _csrf_valid(request, csrf_token): return _csrf_error() - remove_pick(_today_str(), repo_full_name, issue_number) + 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): - enriched = await _enrich_pick(pick) + 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)): + 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) + enriched = await _enrich_pick(pick, workspace_id=ws_id) if enriched and enriched["state"] != "closed": carry_over.append(enriched) candidates = [] if len(picks) < 3: - for group in await _collect_focus_groups(): + 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: @@ -1406,11 +2989,14 @@ async def today_page(request: Request): ) -async def _sprint_markdown(owner: str, repo: str, milestone: str): +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 gitea.get_milestones(owner, repo) + milestones = await client.get_milestones(owner, repo) except Exception: milestones = [] selected = next( @@ -1430,7 +3016,7 @@ async def _sprint_markdown(owner: str, repo: str, milestone: str): or ms.get("title") == selected.get("title") ) - all_items = await gitea.get_repo_issues(owner, repo, state="all") + 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} @@ -1534,11 +3120,16 @@ async def api_sprint(owner: str, repo: str, milestone: str = ""): async def sprint_page(request: Request, owner: str, repo: str, milestone: str = ""): """Sprint story page: rendered narrative + copy/download.""" full_name = f"{owner}/{repo}" - result = await _sprint_markdown(owner, repo, milestone) if milestone else None + 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 gitea.get_milestones(owner, repo) + milestones = await client.get_milestones(owner, repo) except Exception: milestones = [] title, markdown = result if result else ("", "") @@ -1698,13 +3289,21 @@ async def api_rules_run(request: Request, csrf_token: str = Form("")): return {"ok": True, "summary": summary} -async def _project_health(owner: str, repo: str) -> dict: +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() - all_items = await gitea.get_repo_issues(owner, repo, state="all") + 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 + 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)) @@ -1757,12 +3356,18 @@ async def _project_health(owner: str, repo: str) -> dict: @app.get("/health", response_class=HTMLResponse) async def health_index(request: Request): """Flow health, global summary (issue #9).""" - repos = await gitea.search_repos() + 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("/") - return await _project_health(owner, name) + 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 = [] @@ -1791,7 +3396,7 @@ async def health_project(request: Request, owner: str, repo: str): 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 = await gitea.search_repos() + 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}: @@ -1800,7 +3405,10 @@ async def health_project(request: Request, owner: str, repo: str): health=None, full_name=f"{owner}/{repo}", project_names=project_names, ) - health = await _project_health(owner, repo) + 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}", @@ -1839,13 +3447,16 @@ async def forecast_page(request: Request): not answer without reading everything. Per-project pace (weekly bars + backlog ETA) stays as a compact second table. """ - repos = await gitea.search_repos() + 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 gitea.get_milestones(owner, name) + return await client.get_milestones(owner, name) except Exception: return [] @@ -1967,10 +3578,13 @@ async def forecast_page(request: Request): @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(), + groups=await _collect_focus_groups(ws_repos, ws_id), column_labels=COLUMN_LABELS, column_colors=COLUMN_COLORS, ) @@ -2004,24 +3618,33 @@ def _group_issue_results(issues: list[dict]) -> list[dict]: WALL_COLUMNS = ["in_progress", "qa", "review", "todo"] -async def _collect_wall_data(only_repo: str | None) -> list[dict]: +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 [] - known = await gitea.search_repos() - match = [r for r in known if r.get("full_name") == only_repo] + match = [ + r for r in annotated_repos if r.get("full_name") == only_repo + ] if not match: return [] repos = match else: - all_repos = await gitea.search_repos() - active, _inactive, _archived = classify_repos(all_repos) + active, _inactive, _archived = classify_repos(annotated_repos) repos = active[:12] today = datetime.now(timezone.utc).strftime("%Y-%m-%d") @@ -2037,16 +3660,29 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]: 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 gitea.get_repo_issues(owner, name, state="open") + 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)) - board_state = get_board_state(full_name) + _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) + 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: @@ -2079,7 +3715,10 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]: @app.get("/wall", response_class=HTMLResponse) async def wall(request: Request, repo: str = ""): """Wallboard: full-screen, auto-rotating, live project display.""" - slides = await _collect_wall_data(repo or None) + 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", @@ -2093,12 +3732,26 @@ async def wall(request: Request, repo: str = ""): @app.get("/search", response_class=HTMLResponse) async def search_page(request: Request, q: str = ""): - """Global issue search across every repo (Gitea search API).""" + """Global issue search across the workspace's forges.""" q = q.strip() groups: list[dict] = [] total = 0 if q: - results = await gitea.search_issues(q=q, state="all") + 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( @@ -2114,20 +3767,38 @@ async def search_page(request: Request, q: str = ""): @app.get("/my", response_class=HTMLResponse) async def my_issues(request: Request): - """Open issues assigned to the Gitea token's owner, all repos.""" + """Open issues assigned to the token owner, per connection.""" user = None groups: list[dict] = [] total = 0 - try: - user = await gitea.get_user() - except Exception as exc: - logger.debug("get_user failed for /my: %s", exc) - if user and user.get("login"): - results = await gitea.search_issues( - state="open", assigned_by=user["login"] - ) - total = len(results) - groups = _group_issue_results(results) + 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", @@ -2359,7 +4030,12 @@ async def api_briefing_send(request: Request, csrf_token: str = Form("")): # ── Webhooks + live stream ───────────────────────────────── def _apply_external_transition( - full_name: str, number: int, target_column: str, event_type: str + 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. @@ -2367,7 +4043,9 @@ def _apply_external_transition( 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) + 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): @@ -2377,18 +4055,31 @@ def _apply_external_transition( 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) + 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") + 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) -> list[str]: +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 @@ -2405,11 +4096,12 @@ async def _apply_pr_merged(full_name: str, pr: dict) -> list[str]: ) 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 gitea.get_issue(owner, repo, number) + issue = await client.get_issue(owner, repo, number) except Exception: results.append(f"#{number}:unknown") continue @@ -2418,24 +4110,34 @@ async def _apply_pr_merged(full_name: str, pr: dict) -> list[str]: continue if issue.get("state") != "closed": try: - await gitea.set_issue_state(owner, repo, number, "closed") + 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") + 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) + 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") + 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}") @@ -2444,15 +4146,45 @@ async def _apply_pr_merged(full_name: str, pr: dict) -> list[str]: @app.post("/webhooks/gitea") async def gitea_webhook(request: Request): - """Receive Gitea webhooks: verify HMAC, apply external close/reopen, - invalidate the read cache and push a live event to browsers.""" + """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() - if settings.webhook_secret: - signature = request.headers.get("x-gitea-signature", "") - expected = hmac.new( - settings.webhook_secret.encode(), raw, hashlib.sha256 - ).hexdigest() + 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"}, @@ -2465,33 +4197,59 @@ async def gitea_webhook(request: Request): {"ok": False, "error": "Invalid JSON payload"}, status_code=400 ) - event = request.headers.get("x-gitea-event", "") + 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: - if action == "closed": - handled = _apply_external_transition(full_name, number, "done", "closed") - elif action == "reopened": - handled = _apply_external_transition( - full_name, number, "backlog", "reopened" - ) + 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 = await _apply_pr_merged(full_name, pr) + 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") - gitea.invalidate_cache() + 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(gitea, _owner, _repo) + snapshots_service.refresh_repo_snapshots(client, _owner, _repo) ) await hub.broadcast( {"repo": full_name, "event": event, "action": action, "issue": number} @@ -2540,11 +4298,40 @@ async def _do_capture( if parts is None: raise ValueError("Invalid repo (expected owner/name)") owner, repo = parts - issue = await gitea.create_issue(owner, repo, title, body or "") - move_issue(repo_full_name, issue["number"], column) + # Capture has no board context: find the connection holding + # this repo among the global ones (default first), so a card + # can be captured into a GitHub project too (0.24.0). + client, conn_id = gitea, registry.default_id + for candidate in registry.global_clients(): + if candidate is gitea: + continue + try: + names = { + r.get("full_name") for r in await candidate.search_repos() + } + except Exception: + continue + if repo_full_name in names: + client, conn_id = candidate, candidate.connection_id + break + issue = await client.create_issue(owner, repo, title, body or "") + # La carte vit dans le premier espace qui détient la connexion. + ws_id = next( + ( + ws["id"] + for ws in models.list_workspaces() + if conn_id in models.workspace_connection_ids(ws["id"]) + ), + 1, + ) + move_issue( + repo_full_name, issue["number"], column, + workspace_id=ws_id, connection_id=conn_id, + ) log_event( repo_full_name, issue["number"], "created", to_column=column, source="capture", + workspace_id=ws_id, connection_id=conn_id, ) await _publish_board_changed(repo_full_name) return { @@ -2926,24 +4713,35 @@ async def api_move( {"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) + 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) + 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) + 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"} @@ -2951,15 +4749,19 @@ async def api_move( if settings.sync_gitea: try: if to_column == "done": - await gitea.set_issue_state(owner, repo, issue_number, "closed") + 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") + log_event( + repo_full_name, issue_number, "closed", to_column="done", + workspace_id=ws_id, connection_id=conn_id, + ) elif from_column == "done": - await gitea.set_issue_state(owner, repo, issue_number, "open") + 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( @@ -2978,21 +4780,21 @@ async def api_move( desired = ( f"kanban:{to_column}" if to_column in LABELLED_COLUMNS else None ) - issue = await gitea.get_issue(owner, repo, issue_number) + 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 gitea.remove_issue_label( + 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 gitea.ensure_label( + label = await client.ensure_label( owner, repo, desired, COLUMN_COLORS[to_column] ) - await gitea.add_issue_label(owner, repo, issue_number, label["id"]) + await client.add_issue_label(owner, repo, issue_number, label["id"]) result["label_sync"] = "ok" except Exception as exc: logger.warning( @@ -3031,9 +4833,16 @@ async def api_create_issue( {"ok": False, "error": "Invalid column"}, status_code=400 ) owner, repo = parts - issue = await gitea.create_issue(owner, repo, title, body) - move_issue(repo_full_name, issue["number"], column) - log_event(repo_full_name, issue["number"], "created", to_column=column) + 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( { @@ -3089,8 +4898,9 @@ def _comment_payload(comment: dict) -> dict: } -async def _fetch_code_sources(owner: str, repo: str): +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 @@ -3099,16 +4909,18 @@ async def _fetch_code_sources(owner: str, repo: str): return [] branches, pulls, commits = await asyncio.gather( - _safe(gitea.list_branches(owner, repo)), - _safe(gitea.list_pulls(owner, repo)), - _safe(gitea.list_commits(owner, repo)), + _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) -> None: +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) + branches, pulls, commits = await _fetch_code_sources(owner, repo, client) if not (branches or pulls or commits): return for cards in columns.values(): @@ -3140,30 +4952,31 @@ _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 + 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 gitea.get_issue(owner, repo, number) + 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 gitea.remove_issue_label( + await client.remove_issue_label( owner, repo, number, label["id"] ) if desired and all(l.get("name") != desired for l in current): - label = await gitea.ensure_label( + label = await client.ensure_label( owner, repo, desired, COLUMN_COLORS[column] ) - await gitea.add_issue_label(owner, repo, number, label["id"]) + 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 @@ -3187,8 +5000,9 @@ async def api_create_branch( """ 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 gitea.get_issue(owner, repo, number) + issue = await client.get_issue(owner, repo, number) except Exception: return JSONResponse( {"ok": False, "error": "Issue introuvable"}, status_code=404 @@ -3212,12 +5026,17 @@ async def api_create_branch( ) repo_info = {} try: - repo_info = await gitea.get_repo(owner, repo) + repo_info = await client.get_repo(owner, repo) except Exception: pass base_url = repo_info.get("html_url", "") - branch_url = f"{base_url}/src/branch/{branch}" if base_url else "" - existing = await _safe_list_branches(owner, repo) + 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, @@ -3225,18 +5044,20 @@ async def api_create_branch( } default_branch = repo_info.get("default_branch") or "main" try: - await gitea.create_branch(owner, repo, branch, default_branch) + 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, ) - gitea.invalidate_cache() + client.invalidate_cache() full_name = f"{owner}/{repo}" moved = False if issue.get("state") == "open": - board = get_board_state(full_name) + board = get_board_state( + full_name, workspace_id=ws_id, connection_id=conn_id + ) current = next( ( col for col, cards in board.items() @@ -3245,13 +5066,19 @@ async def api_create_branch( "backlog", ) if current != "in_progress": - move_issue(full_name, number, "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 ) - await _sync_kanban_label(owner, repo, number, "in_progress") moved = True await _publish_board_changed(full_name) return { @@ -3260,19 +5087,27 @@ async def api_create_branch( } -async def _safe_list_branches(owner: str, repo: str) -> list[str]: +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 gitea.list_branches(owner, repo)] + 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(owner: str, repo: str, number: int): - """Issue detail + comments for the card panel (proxied from Gitea).""" +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 gitea.get_issue(owner, repo, number) - comments = await gitea.get_issue_comments(owner, repo, number) + 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( @@ -3308,8 +5143,12 @@ async def api_issue_comment( {"ok": False, "error": "Comment too large (max 10000 chars)"}, status_code=413, ) - comment = await gitea.create_comment(owner, repo, number, body) - log_event(f"{owner}/{repo}", number, "commented") + 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 ) @@ -3352,9 +5191,10 @@ def _repo_forecast_label(repo_full_name: str, remaining: int) -> str | None: # ── Label management ─────────────────────────────────────── @app.get("/api/repos/{owner}/{repo}/labels") -async def api_list_labels(owner: str, repo: str): +async def api_list_labels(request: Request, owner: str, repo: str): """All label definitions of a repo.""" - labels = await gitea.get_labels(owner, repo) + client, _cid, _ws = await _resolve_board_scope(request, owner, repo) + labels = await client.get_labels(owner, repo) return { "ok": True, "labels": [ @@ -3388,8 +5228,9 @@ async def api_create_label( {"ok": False, "error": "Color must be a 6-digit hex value"}, status_code=400, ) - label = await gitea.create_label(owner, repo, name, color) - gitea.forget_label_cache(owner, repo) + 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, @@ -3405,12 +5246,17 @@ async def api_create_label( @app.delete("/api/repos/{owner}/{repo}/labels/{label_id}") async def api_delete_label( - request: Request, owner: str, repo: str, label_id: int + request: Request, owner: str, repo: str, label_id: str ): - """Delete a label definition (CSRF via the X-CSRF-Token header).""" + """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() - await gitea.delete_label(owner, repo, label_id) + client, _cid, _ws = await _resolve_board_scope(request, owner, repo) + await client.delete_label(owner, repo, label_id) return {"ok": True, "deleted": label_id} @@ -3420,24 +5266,27 @@ async def api_toggle_issue_label( owner: str, repo: str, number: int, - label_id: int = Form(...), + 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 gitea.add_issue_label(owner, repo, number, label_id) + await client.add_issue_label(owner, repo, number, label_id) else: - await gitea.remove_issue_label(owner, repo, number, label_id) + await client.remove_issue_label(owner, repo, number, label_id) await _publish_board_changed(f"{owner}/{repo}") - issue = await gitea.get_issue(owner, repo, number) + issue = await client.get_issue(owner, repo, number) return { "ok": True, "labels": [ @@ -3693,7 +5542,17 @@ async def api_notes( }, status_code=413, ) - set_notes(repo_full_name, note) + 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} diff --git a/app/templates/_header.html b/app/templates/_header.html index becafe5..a3a5fac 100644 --- a/app/templates/_header.html +++ b/app/templates/_header.html @@ -28,9 +28,20 @@ ("outils", "Outils", [ ("search", "/search", "🔍", "Recherche"), ("capture", "/capture", "⚡", "Capture"), + ("archives", "/archives", "🗄️", "Archives"), ("wall", "/wall", "🖥️", "Wallboard"), ]), ] %} +{% if is_admin %} +{% set nav_groups = nav_groups + [ + ("admin", "Administration", [ + ("admin", "/admin", "🛠️", "Vue d'ensemble"), + ("admin_connections", "/admin/connections", "🔌", "Connexions"), + ("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"), + ("admin_settings", "/admin/settings", "🎛️", "Paramètres"), + ]), +] %} +{% endif %} {% set active_group = namespace(key="principal") %} {% for gkey, group, links in nav_groups %} {% for key, href, icon, label in links %} @@ -49,7 +60,7 @@ localStorage.removeItem('kanbante-nav-closed'); var openGroup = {{ active_group.key | tojson }}; var stored = localStorage.getItem('kanbante-nav-open'); - var groups = ['principal', 'rituels', 'pilotage', 'outils']; + var groups = ['principal', 'rituels', 'pilotage', 'outils', 'admin']; if ({{ (active_nav != "") | tojson }}) { localStorage.setItem('kanbante-nav-open', openGroup); } else if (stored === '') { @@ -71,6 +82,17 @@ + {% if current_workspace and user_workspaces and user_workspaces|length > 1 %} +
+ + + +
+ {% endif %}