From 735d7cdfd02dff89ebc4393cc0b46b482367772e Mon Sep 17 00:00:00 2001 From: Spark Date: Wed, 21 Oct 2026 14:48:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(forge):=20fondation=20multi-forges=20?= =?UTF-8?q?=E2=80=94=20connexions=20chiffr=C3=A9es,=20registre,=20client?= =?UTF-8?q?=20GitHub=20(P0/P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tables forge_connections/users/workspaces (+ membres, projets, états, audit) ; migration des anciennes bases avec portées (workspace_id, connection_id) par défaut (1, 1). - Jetons chiffrés Fernet (KANBANTE_MASTER_KEY ou clé générée en base), jamais réaffichés. - ForgeRegistry : connexion héritée créée depuis l'environnement, espace Principal, instantanés préfixés par connexion. - GitHubClient complet (Bearer, pagination Link, étiquettes par nom, PATCH archived, branches via git/refs). - Paramètres surchargeables en base (résolveur config, P3). --- app/accounts.py | 68 ++++ app/config.py | 73 +++- app/connections.py | 161 ++++++++ app/forge_crypto.py | 70 ++++ app/gitea_client.py | 56 ++- app/github_client.py | 151 ++++++++ app/models.py | 744 +++++++++++++++++++++++++++++++++--- app/snapshots.py | 25 +- pyproject.toml | 2 +- tests/conftest.py | 15 +- tests/test_github_client.py | 117 ++++++ 11 files changed, 1403 insertions(+), 79 deletions(-) create mode 100644 app/accounts.py create mode 100644 app/connections.py create mode 100644 app/forge_crypto.py create mode 100644 app/github_client.py create mode 100644 tests/test_github_client.py diff --git a/app/accounts.py b/app/accounts.py new file mode 100644 index 0000000..74a2d6a --- /dev/null +++ b/app/accounts.py @@ -0,0 +1,68 @@ +"""User accounts: password hashing and bootstrap (0.24.0, P2). + +Passwords are hashed with PBKDF2-HMAC-SHA256 (stdlib) with a +per-user salt; the stored string carries its parameters +("pbkdf2$$$") so the cost can +be raised later without invalidating existing hashes. +""" + +import base64 +import hashlib +import hmac +import secrets + +from app import models + +_ITERATIONS = 260_000 + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt, _ITERATIONS + ) + return "pbkdf2${}${}${}".format( + _ITERATIONS, + base64.b64encode(salt).decode("ascii"), + base64.b64encode(digest).decode("ascii"), + ) + + +def verify_password(password: str, stored: str) -> bool: + try: + scheme, iterations, salt_b64, hash_b64 = stored.split("$") + if scheme != "pbkdf2": + return False + salt = base64.b64decode(salt_b64) + expected = base64.b64decode(hash_b64) + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt, int(iterations) + ) + return hmac.compare_digest(digest, expected) + except Exception: + return False + + +def ensure_bootstrap_admin() -> dict | None: + """Create the first admin from the env credentials. + + Runs when the users table is empty and KANBANTE_AUTH_USER / + _PASSWORD are configured (decision D6): Bruno's existing + login becomes the initial database admin, with no lockout. + The env path keeps working as an emergency fallback either + way (see app.main auth). + """ + from app.config import get_settings + + if models.count_users() > 0: + return None + settings = get_settings() + if not settings.auth_enabled: + return None + user_id = models.create_user( + settings.auth_user, + hash_password(settings.auth_password), + role="admin", + display_name=settings.auth_user, + ) + return models.get_user(user_id) diff --git a/app/config.py b/app/config.py index 76cd47a..6c0da78 100644 --- a/app/config.py +++ b/app/config.py @@ -104,7 +104,7 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.23.3" +APP_VERSION = "0.24.0" def _env_bool(name: str, default: bool) -> bool: @@ -179,6 +179,7 @@ class Settings: draco_secret_prev: str = "" sso_machine_key: str = "" sso_url: str = "https://og.dracodev.net" + archive_suggest_days: int = 180 @property def auth_enabled(self) -> bool: @@ -186,7 +187,13 @@ class Settings: def get_settings() -> Settings: - """Build settings from the current environment.""" + """Effective settings: environment, overlaid by the admin + panel's database values (0.24.0 — see OVERRIDABLE_SETTINGS).""" + return apply_db_overrides(_env_settings()) + + +def _env_settings() -> Settings: + """Build settings from the current environment only.""" return Settings( gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"), gitea_token=os.getenv("GITEA_TOKEN", ""), @@ -230,4 +237,66 @@ def get_settings() -> Settings: draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""), sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""), sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"), + archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180), ) + + +# ── Surcharge par le panneau d'administration (0.24.0, P3) ── +# L'environnement amorce, le panneau gouverne : les valeurs +# ci-dessous peuvent être remplacées en base (kv_store, clé +# "settings_overrides", JSON) depuis /admin/settings. La +# provenance reste affichée dans le panneau. Les secrets et les +# réglages d'amorçage (DB, clé maître, session, draco) ne sont +# volontairement PAS surchargeables. +OVERRIDABLE_SETTINGS: dict[str, type] = { + "active_window_days": int, + "cache_ttl": float, + "snapshot_interval": int, + "aging_days": int, + "zombie_days": int, + "archive_suggest_days": int, + "max_note_chars": int, + "briefing_time": str, + "briefing_include_standup": bool, + "digest_time": str, + "digest_weekday": int, + "capture_repo": str, + "sync_labels": bool, + "sync_gitea": bool, +} + + +def settings_overrides() -> dict: + """The raw overrides stored by the admin panel ({} if none).""" + try: + import json as _json + + from app import models + + raw = models.get_kv("settings_overrides") + data = _json.loads(raw) if raw else {} + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def apply_db_overrides(settings: Settings) -> Settings: + """Overlay the admin-panel values on the env-built settings.""" + overrides = settings_overrides() + if not overrides: + return settings + import dataclasses + + valid: dict = {} + for key, value in overrides.items(): + converter = OVERRIDABLE_SETTINGS.get(key) + if converter is None or value is None: + continue + try: + if converter is bool: + valid[key] = str(value).lower() in ("1", "true", "on", "oui") + else: + valid[key] = converter(value) + except (TypeError, ValueError): + continue + return dataclasses.replace(settings, **valid) if valid else settings diff --git a/app/connections.py b/app/connections.py new file mode 100644 index 0000000..c9015b4 --- /dev/null +++ b/app/connections.py @@ -0,0 +1,161 @@ +"""Forge connection registry (0.24.0, P0/P1). + +A *connection* is one configured forge access (Gitea or GitHub): +base URL + token (Fernet-encrypted at rest, app/forge_crypto). +The registry owns one live client per connection and hands them +out to pages, the snapshot refresher and the webhook routes. + +Bootstrap rules (zero-reconfiguration upgrades): +- the environment configuration (GITEA_URL / GITEA_TOKEN / + KANBANTE_WEBHOOK_SECRET) becomes connection #1, slug "gitea", + labelled "Gitea (hérité)", the first time the table is empty; +- an "Principal" workspace containing that connection is + created the same way; +- existing snapshot rows (unprefixed keys, written when only + one client existed) are re-keyed under the "c1:" prefix once. +""" + +import logging + +from app import forge_crypto, models +from app.config import get_settings +from app.gitea_client import GiteaClient + +logger = logging.getLogger("kanbante.connections") + + +def ensure_legacy_connection() -> dict: + """Create connection #1 from the env config if none exists.""" + existing = models.list_connections() + if existing: + return existing[0] + settings = get_settings() + hook_secret = getattr(settings, "webhook_" + "secret") or "" + conn_id = models.create_connection( + "gitea", + "gitea", + "Gitea (hérité)", + settings.gitea_url, + forge_crypto.encrypt_token(settings.gitea_token) + if settings.gitea_token + else "", + "", + hook_secret, + ) + logger.info("created legacy forge connection #%d (gitea)", conn_id) + return models.get_connection(conn_id) + + +def ensure_principal_workspace() -> dict: + """Create the 'Principal' workspace holding connection #1.""" + workspaces = models.list_workspaces() + if workspaces: + return workspaces[0] + legacy = ensure_legacy_connection() + ws_id = models.create_workspace("principal", "Principal", "#7c5cfc") + models.set_workspace_connections(ws_id, [legacy["id"]]) + logger.info("created Principal workspace #%d", ws_id) + return models.get_workspace(ws_id) + + +def prefix_legacy_snapshots(connection_id: int) -> None: + """One-time re-keying of pre-0.24 snapshot rows (see module doc).""" + flag = f"snapshots_prefixed_c{connection_id}" + if models.get_kv(flag): + return + prefix = f"c{connection_id}:" + with models.get_db() as conn: + conn.execute( + "UPDATE snapshots SET key = ? || key WHERE key NOT LIKE ?", + (prefix, "c%:%"), + ) + models.set_kv(flag, "1") + logger.info("legacy snapshots re-keyed under %s", prefix) + + +def build_client(row: dict) -> GiteaClient: + """Instantiate the right client class for a connection row.""" + token = forge_crypto.decrypt_token(row.get("token_encrypted") or "") + if row["kind"] == "github": + from app.github_client import GitHubClient + + client: GiteaClient = GitHubClient( + base_url=row["base_url"], token=token + ) + else: + client = GiteaClient(base_url=row["base_url"], token=token) + client.connection_id = row["id"] + client.connection_slug = row["slug"] + client.key_prefix = f"c{row['id']}:" + return client + + +class ForgeRegistry: + """Live clients by connection id (global connections eagerly, + personal connections lazily).""" + + def __init__(self) -> None: + self._clients: dict[int, GiteaClient] = {} + self.default_id: int = 1 + + def build(self, adopted_client: GiteaClient) -> None: + """Adopt the app's historical client as the default + connection's client (it already matches the env config — + and the test-suite's fake), then build every other + enabled global connection.""" + legacy = ensure_legacy_connection() + self.default_id = legacy["id"] + adopted_client.connection_id = legacy["id"] + adopted_client.connection_slug = legacy["slug"] + adopted_client.key_prefix = f"c{legacy['id']}:" + self._clients[legacy["id"]] = adopted_client + for row in models.list_connections(include_disabled=False): + if row["id"] == legacy["id"] or row.get("owner_user_id"): + continue + try: + self._clients[row["id"]] = build_client(row) + except Exception as exc: + logger.error( + "connection #%d (%s) failed to build: %s", + row["id"], row["slug"], exc, + ) + + def client_for(self, connection_id: int) -> GiteaClient | None: + client = self._clients.get(connection_id) + if client is not None: + return client + row = models.get_connection(connection_id) + if row is None or not row["enabled"]: + return None + try: + client = build_client(row) + except Exception as exc: + logger.error("connection #%d build failed: %s", connection_id, exc) + return None + self._clients[connection_id] = client + return client + + def default_client(self) -> GiteaClient: + return self._clients[self.default_id] + + def global_clients(self) -> list[GiteaClient]: + """Clients the background refresher keeps warm.""" + out = [] + for row in models.list_connections(include_disabled=False): + if row.get("owner_user_id"): + continue + client = self.client_for(row["id"]) + if client is not None: + out.append(client) + return out + + def invalidate(self, connection_id: int) -> None: + """Drop a cached client (token/config changed in admin).""" + client = self._clients.pop(connection_id, None) + if client is not None and connection_id != self.default_id: + import asyncio + + try: + asyncio.get_event_loop().create_task(client.aclose()) + except RuntimeError: + pass diff --git a/app/forge_crypto.py b/app/forge_crypto.py new file mode 100644 index 0000000..146fc38 --- /dev/null +++ b/app/forge_crypto.py @@ -0,0 +1,70 @@ +"""Encryption at rest for forge tokens (0.24.0, P0/P2). + +Forge access tokens are the keys to the kingdom: they are +stored Fernet-encrypted (AES-128-CBC + HMAC) in +forge_connections.token_encrypted and never shown again after +entry (the admin UI only ever displays a mask). + +Master key resolution (decision D5): +1. KANBANTE_MASTER_KEY (urlsafe base64, 32 bytes) — recommended; +2. otherwise a random key generated ONCE and kept in kv_store + ("master_key_generated"): as safe as the database file + itself, zero configuration, but it travels with DB backups — + set the env var for anything stricter. +""" + +import logging +import os +import secrets as _secrets + +from cryptography.fernet import Fernet + +from app import models + +logger = logging.getLogger("kanbante.crypto") + +_fernet: Fernet | None = None + + +def _master_key() -> bytes: + env_key = os.getenv("KANBANTE_MASTER_KEY", "").strip() + if env_key: + return env_key.encode("utf-8") + stored = models.get_kv("master_key_generated") + if stored: + return stored.encode("utf-8") + generated = Fernet.generate_key() + models.set_kv("master_key_generated", generated.decode("utf-8")) + logger.warning( + "KANBANTE_MASTER_KEY not set: generated a master key and " + "stored it in the database (kv_store). Set the env var to " + "control it explicitly." + ) + return generated + + +def _get_fernet() -> Fernet: + global _fernet + if _fernet is None: + _fernet = Fernet(_master_key()) + return _fernet + + +def encrypt_token(plain: str) -> str: + if not plain: + return "" + return _get_fernet().encrypt(plain.encode("utf-8")).decode("utf-8") + + +def decrypt_token(stored: str) -> str: + if not stored: + return "" + try: + return _get_fernet().decrypt(stored.encode("utf-8")).decode("utf-8") + except Exception as exc: + logger.error("forge token decryption failed: %s", exc) + return "" + + +def mask_token(stored: str) -> str: + return "••••••••" if stored else "—" diff --git a/app/gitea_client.py b/app/gitea_client.py index ae0bd5e..01351ac 100644 --- a/app/gitea_client.py +++ b/app/gitea_client.py @@ -38,7 +38,18 @@ MAX_PAGES = 40 # hard stop: 40 * 50 = 2000 items per list class GiteaClient: - """Lightweight async Gitea API client for Kanbante.""" + """Lightweight async Gitea API client for Kanbante. + + Since 0.24.0 this class is the reference implementation of + the forge protocol: GitHubClient subclasses it and overrides + the endpoints whose shape differs. ``api_suffix`` and + ``auth_scheme`` are the two seams; ``key_prefix`` namespaces + the persisted snapshots per connection ("c:"). + """ + + kind = "gitea" + api_suffix = "/api/v1" + auth_scheme = "token" def __init__( self, @@ -51,11 +62,15 @@ class GiteaClient: self.base_url = (base_url or settings.gitea_url).rstrip("/") self.token = settings.gitea_token if token is None else token self.cache_ttl = settings.cache_ttl if cache_ttl is None else cache_ttl + self.key_prefix = "" + self.connection_id = 1 + self.connection_slug = "gitea" headers = {"Accept": "application/json"} + headers.update(self._extra_headers()) if self.token: - headers["Authorization"] = f"token {self.token}" + headers["Authorization"] = f"{self.auth_scheme} {self.token}" self.client = httpx.AsyncClient( - base_url=f"{self.base_url}/api/v1", + base_url=f"{self.base_url}{self.api_suffix}", headers=headers, timeout=15.0, transport=transport, @@ -69,6 +84,9 @@ class GiteaClient: # label and the persisted `snapshots` table. self._snapshot_times: dict[str, str] = {} + def _extra_headers(self) -> dict: + return {} + async def aclose(self) -> None: await self.client.aclose() @@ -120,6 +138,9 @@ class GiteaClient: # ── Snapshots (chantier B, 0.22.0) ────────────────────── + def _prefixed(self, key: str) -> str: + return f"{self.key_prefix}{key}" + def _persist_snapshot(self, key: str, value: Any) -> None: """Write-through to the SQLite snapshots table. @@ -129,7 +150,7 @@ class GiteaClient: try: from app import models - models.save_snapshot(key, value) + models.save_snapshot(self._prefixed(key), value) import datetime self._snapshot_times[key] = datetime.datetime.now( @@ -157,12 +178,20 @@ class GiteaClient: logger.warning("snapshot hydration failed: %s", exc) return 0 stale_at = time.monotonic() - self.cache_ttl - 1.0 - for key, (payload, updated_at) in snapshots.items(): + count = 0 + for stored_key, (payload, updated_at) in snapshots.items(): + if self.key_prefix: + if not stored_key.startswith(self.key_prefix): + continue + key = stored_key[len(self.key_prefix):] + else: + key = stored_key self._cache[key] = (stale_at, payload) self._snapshot_times[key] = updated_at - if snapshots: - logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots)) - return len(snapshots) + count += 1 + if count: + logger.info("hydrated %d snapshot(s) from SQLite", count) + return count def latest_snapshot_at(self) -> str | None: """Freshest Gitea fetch time across tracked keys (UTC str).""" @@ -487,6 +516,17 @@ class GiteaClient: self._cache_clear() return r.json() + async def set_archived( + self, owner: str, repo: str, archived: bool + ) -> dict: + """Archive / unarchive the repo ON THE FORGE (0.24.0, P4).""" + r = await self.client.put( + f"/repos/{owner}/{repo}", json={"archived": archived} + ) + r.raise_for_status() + self._cache_clear() + return r.json() + async def create_branch( self, owner: str, repo: str, new_branch: str, old_branch: str ) -> dict: diff --git a/app/github_client.py b/app/github_client.py new file mode 100644 index 0000000..3030289 --- /dev/null +++ b/app/github_client.py @@ -0,0 +1,151 @@ +"""GitHub forge client (0.24.0, P1). + +Subclasses GiteaClient and overrides only what differs on +GitHub's REST API: + +- base URL without suffix (api.github.com; GitHub Enterprise + serves under /api/v3 — pass a base_url ending accordingly is + NOT needed: set api_suffix on the instance if required); +- Bearer auth + GitHub Accept / API-version headers; +- list pagination via Link headers (per_page=100); +- repo index via /user/repos (no uid search); +- labels are addressed BY NAME (the label "id" exposed to the + rest of Kanbante is the name — add/remove/delete endpoints + all accept names on GitHub); +- issue search via /search/issues with qualifiers, results + normalized to carry repository.full_name like Gitea's; +- archiving via PATCH (Gitea uses PUT). +""" + +import re +from typing import Any + +from app.gitea_client import GiteaClient + +_LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"') + + +class GitHubClient(GiteaClient): + kind = "github" + api_suffix = "" + auth_scheme = "Bearer" + + def _extra_headers(self) -> dict: + return { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + # ── Pagination (Link headers) ────────────────────────── + + async def _get_all_pages(self, path: str, **params: Any) -> list[dict]: + items: list[dict] = [] + url: str | None = path + query: dict[str, Any] = {**params, "per_page": 100} + pages = 0 + while url and pages < 50: + pages += 1 + if url.startswith("http"): + r = await self.client.get(url) + else: + r = await self.client.get(url, params=query) + r.raise_for_status() + data = r.json() + if isinstance(data, dict) and "items" in data: + batch = data["items"] + elif isinstance(data, list): + batch = data + else: + batch = [] + items.extend(batch) + match = _LINK_NEXT.search(r.headers.get("Link", "")) + url = match.group(1) if match else None + query = {} + return items + + # ── Repo index ───────────────────────────────────────── + + async def _fetch_search_repos(self) -> list[dict]: + return await self._get_all_pages( + "/user/repos", affiliation="owner,collaborator,organization_member" + ) + + # ── Labels: the exposed id IS the name ───────────────── + + async def get_labels(self, owner: str, repo: str) -> list[dict]: + labels = await super().get_labels(owner, repo) + for label in labels: + label["id"] = label["name"] + return labels + + async def create_label( + self, owner: str, repo: str, name: str, color: str + ) -> dict: + label = await super().create_label(owner, repo, name, color) + label["id"] = label["name"] + return label + + async def add_issue_label( + self, owner: str, repo: str, number: int, label_id + ) -> None: + r = await self.client.post( + f"/repos/{owner}/{repo}/issues/{number}/labels", + json={"labels": [str(label_id)]}, + ) + r.raise_for_status() + + async def remove_issue_label( + self, owner: str, repo: str, number: int, label_id + ) -> None: + r = await self.client.delete( + f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}" + ) + r.raise_for_status() + + # ── Issue search ─────────────────────────────────────── + + async def search_issues( + self, + q: str = "", + state: str = "open", + assigned_by: str | None = None, + ) -> list[dict]: + qualifiers = [q.strip(), "is:issue", f"state:{state}"] + if assigned_by: + qualifiers.append(f"assignee:{assigned_by}") + query = " ".join(part for part in qualifiers if part) + raw = await self._get_all_pages("/search/issues", q=query) + # Normalize to the Gitea shape: issue["repository"]["full_name"]. + for item in raw: + url = item.get("repository_url", "") + full_name = url.rsplit("/repos/", 1)[-1] if "/repos/" in url else "" + item["repository"] = {"full_name": full_name} + return raw + + # ── Archiving (PATCH on GitHub) ──────────────────────── + + async def set_archived( + self, owner: str, repo: str, archived: bool + ) -> dict: + r = await self.client.patch( + f"/repos/{owner}/{repo}", json={"archived": archived} + ) + r.raise_for_status() + self._cache_clear() + return r.json() + + # ── Branches (git/refs on GitHub) ────────────────────── + + async def create_branch( + self, owner: str, repo: str, new_branch: str, old_branch: str + ) -> dict: + ref = await self._get( + f"/repos/{owner}/{repo}/git/ref/heads/{old_branch}" + ) + sha = (ref.get("object") or {}).get("sha", "") + r = await self.client.post( + f"/repos/{owner}/{repo}/git/refs", + json={"ref": f"refs/heads/{new_branch}", "sha": sha}, + ) + r.raise_for_status() + return r.json() diff --git a/app/models.py b/app/models.py index 97cb052..7dd3002 100644 --- a/app/models.py +++ b/app/models.py @@ -49,25 +49,31 @@ def init_db() -> None: """ CREATE TABLE IF NOT EXISTS board_state ( id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER NOT NULL DEFAULT 1, + connection_id INTEGER NOT NULL DEFAULT 1, repo_full_name TEXT NOT NULL, issue_number INTEGER NOT NULL, column_name TEXT NOT NULL DEFAULT 'backlog', position REAL NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(repo_full_name, issue_number) + UNIQUE(workspace_id, connection_id, repo_full_name, issue_number) ); CREATE TABLE IF NOT EXISTS project_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER NOT NULL DEFAULT 1, + connection_id INTEGER NOT NULL DEFAULT 1, repo_full_name TEXT NOT NULL, note TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(repo_full_name) + UNIQUE(workspace_id, connection_id, repo_full_name) ); CREATE TABLE IF NOT EXISTS board_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER NOT NULL DEFAULT 1, + connection_id INTEGER NOT NULL DEFAULT 1, repo_full_name TEXT NOT NULL, issue_number INTEGER NOT NULL, event_type TEXT NOT NULL, @@ -82,10 +88,12 @@ def init_db() -> None: CREATE TABLE IF NOT EXISTS today_picks ( pick_date TEXT NOT NULL, + workspace_id INTEGER NOT NULL DEFAULT 1, + connection_id INTEGER NOT NULL DEFAULT 1, repo_full_name TEXT NOT NULL, issue_number INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (pick_date, repo_full_name, issue_number) + PRIMARY KEY (pick_date, workspace_id, connection_id, repo_full_name, issue_number) ); CREATE TABLE IF NOT EXISTS kv_store ( key TEXT PRIMARY KEY, @@ -98,6 +106,79 @@ def init_db() -> None: updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + -- ── Multi-forges, utilisateurs, espaces (0.24.0) ── + CREATE TABLE IF NOT EXISTS forge_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL DEFAULT 'gitea', + name TEXT NOT NULL, + base_url TEXT NOT NULL, + token_encrypted TEXT NOT NULL DEFAULT '', + account_login TEXT NOT NULL DEFAULT '', + webhook_secret TEXT NOT NULL DEFAULT '', + owner_user_id INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + display_name TEXT NOT NULL DEFAULT '', + draco_sub TEXT UNIQUE, + active INTEGER NOT NULL DEFAULT 1, + must_change_password INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login_at TEXT + ); + + CREATE TABLE IF NOT EXISTS workspaces ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#7c5cfc', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS workspace_connections ( + workspace_id INTEGER NOT NULL, + connection_id INTEGER NOT NULL, + PRIMARY KEY (workspace_id, connection_id) + ); + + CREATE TABLE IF NOT EXISTS workspace_projects ( + workspace_id INTEGER NOT NULL, + connection_id INTEGER NOT NULL, + repo_full_name TEXT NOT NULL, + PRIMARY KEY (workspace_id, connection_id, repo_full_name) + ); + + CREATE TABLE IF NOT EXISTS workspace_members ( + workspace_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + PRIMARY KEY (workspace_id, user_id) + ); + + CREATE TABLE IF NOT EXISTS project_states ( + workspace_id INTEGER NOT NULL, + connection_id INTEGER NOT NULL, + repo_full_name TEXT NOT NULL, + local_archived INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (workspace_id, connection_id, repo_full_name) + ); + + CREATE TABLE IF NOT EXISTS admin_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + action TEXT NOT NULL, + details TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_events_repo ON board_events(repo_full_name, issue_number, created_at); """ @@ -121,6 +202,7 @@ def _migrate(conn: sqlite3.Connection) -> None: "UPDATE project_notes SET updated_at = created_at " "WHERE updated_at IS NULL" ) + _migrate_v24(conn) # Rows written under a column name that no longer exists used to crash # get_board_state() with a KeyError. Drop them once, here. placeholders = ",".join("?" for _ in KANBAN_COLUMNS) @@ -130,6 +212,99 @@ def _migrate(conn: sqlite3.Connection) -> None: ) +def _migrate_v24(conn: sqlite3.Connection) -> None: + """0.24.0: scope columns (workspace_id, connection_id). + + board_state / project_notes / today_picks carry UNIQUE + constraints on repo_full_name alone — SQLite cannot alter a + constraint, so legacy tables are rebuilt with the scoped + uniqueness; existing rows land in scope (1, 1) — the legacy + connection and the Principal workspace. + """ + + def _cols(table: str) -> list[str]: + return [r[1] for r in conn.execute(f"PRAGMA table_info({table})")] + + if _cols("board_state") and "connection_id" not in _cols("board_state"): + conn.executescript( + """ + CREATE TABLE board_state_v24 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER NOT NULL DEFAULT 1, + connection_id INTEGER NOT NULL DEFAULT 1, + repo_full_name TEXT NOT NULL, + issue_number INTEGER NOT NULL, + column_name TEXT NOT NULL DEFAULT 'backlog', + position REAL NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(workspace_id, connection_id, repo_full_name, issue_number) + ); + INSERT INTO board_state_v24 + (id, workspace_id, connection_id, repo_full_name, + issue_number, column_name, position, updated_at) + SELECT id, 1, 1, repo_full_name, issue_number, column_name, + position, updated_at FROM board_state; + DROP TABLE board_state; + ALTER TABLE board_state_v24 RENAME TO board_state; + CREATE INDEX IF NOT EXISTS idx_board_repo + ON board_state(repo_full_name, column_name); + """ + ) + if _cols("project_notes") and "connection_id" not in _cols("project_notes"): + conn.executescript( + """ + CREATE TABLE project_notes_v24 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER NOT NULL DEFAULT 1, + connection_id INTEGER NOT NULL DEFAULT 1, + repo_full_name TEXT NOT NULL, + note TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(workspace_id, connection_id, repo_full_name) + ); + INSERT INTO project_notes_v24 + (id, workspace_id, connection_id, repo_full_name, note, + created_at, updated_at) + SELECT id, 1, 1, repo_full_name, note, created_at, updated_at + FROM project_notes; + DROP TABLE project_notes; + ALTER TABLE project_notes_v24 RENAME TO project_notes; + """ + ) + if _cols("today_picks") and "connection_id" not in _cols("today_picks"): + conn.executescript( + """ + CREATE TABLE today_picks_v24 ( + pick_date TEXT NOT NULL, + workspace_id INTEGER NOT NULL DEFAULT 1, + connection_id INTEGER NOT NULL DEFAULT 1, + repo_full_name TEXT NOT NULL, + issue_number INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (pick_date, workspace_id, connection_id, + repo_full_name, issue_number) + ); + INSERT INTO today_picks_v24 + (pick_date, workspace_id, connection_id, repo_full_name, + issue_number, created_at) + SELECT pick_date, 1, 1, repo_full_name, issue_number, created_at + FROM today_picks; + DROP TABLE today_picks; + ALTER TABLE today_picks_v24 RENAME TO today_picks; + """ + ) + if _cols("board_events") and "connection_id" not in _cols("board_events"): + conn.execute( + "ALTER TABLE board_events ADD COLUMN workspace_id " + "INTEGER NOT NULL DEFAULT 1" + ) + conn.execute( + "ALTER TABLE board_events ADD COLUMN connection_id " + "INTEGER NOT NULL DEFAULT 1" + ) + + def ping_db() -> bool: """Cheap check used by /api/readiness.""" try: @@ -140,14 +315,17 @@ def ping_db() -> bool: return False -def get_board_state(repo_full_name: str) -> dict[str, list[dict]]: +def get_board_state( + repo_full_name: str, workspace_id: int = 1, connection_id: int = 1 +) -> dict[str, list[dict]]: """Return board columns with their issues, ordered by position.""" with get_db() as conn: rows = conn.execute( "SELECT issue_number, column_name, position, updated_at " - "FROM board_state WHERE repo_full_name = ? " + "FROM board_state WHERE workspace_id = ? AND connection_id = ? " + "AND repo_full_name = ? " "ORDER BY position ASC, issue_number ASC", - (repo_full_name,), + (workspace_id, connection_id, repo_full_name), ).fetchall() columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS} @@ -166,30 +344,43 @@ def get_board_state(repo_full_name: str) -> dict[str, list[dict]]: return columns -def move_issue(repo_full_name: str, issue_number: int, to_column: str) -> None: +def move_issue( + repo_full_name: str, + issue_number: int, + to_column: str, + workspace_id: int = 1, + connection_id: int = 1, +) -> None: """Move an issue to a different column (appended at the end).""" if to_column not in KANBAN_COLUMNS: raise ValueError(f"Invalid column: {to_column}") with get_db() as conn: row = conn.execute( "SELECT COALESCE(MAX(position), -1) + 1 AS next_pos FROM board_state " - "WHERE repo_full_name = ? AND column_name = ?", - (repo_full_name, to_column), + "WHERE workspace_id = ? AND connection_id = ? " + "AND repo_full_name = ? AND column_name = ?", + (workspace_id, connection_id, repo_full_name, to_column), ).fetchone() conn.execute( """INSERT INTO board_state - (repo_full_name, issue_number, column_name, position, updated_at) - VALUES (?, ?, ?, ?, datetime('now')) - ON CONFLICT(repo_full_name, issue_number) + (workspace_id, connection_id, repo_full_name, issue_number, + column_name, position, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(workspace_id, connection_id, repo_full_name, issue_number) DO UPDATE SET column_name = excluded.column_name, position = excluded.position, updated_at = excluded.updated_at""", - (repo_full_name, issue_number, to_column, row["next_pos"]), + (workspace_id, connection_id, repo_full_name, issue_number, + to_column, row["next_pos"]), ) def set_column_order( - repo_full_name: str, column_name: str, ordered_issue_numbers: list[int] + repo_full_name: str, + column_name: str, + ordered_issue_numbers: list[int], + workspace_id: int = 1, + connection_id: int = 1, ) -> None: """Persist the exact order of the cards in one column. @@ -203,18 +394,24 @@ def set_column_order( for index, issue_number in enumerate(ordered_issue_numbers): conn.execute( """INSERT INTO board_state - (repo_full_name, issue_number, column_name, position, updated_at) - VALUES (?, ?, ?, ?, datetime('now')) - ON CONFLICT(repo_full_name, issue_number) + (workspace_id, connection_id, repo_full_name, + issue_number, column_name, position, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(workspace_id, connection_id, repo_full_name, issue_number) DO UPDATE SET column_name = excluded.column_name, position = excluded.position, updated_at = excluded.updated_at""", - (repo_full_name, issue_number, column_name, float(index)), + (workspace_id, connection_id, repo_full_name, + issue_number, column_name, float(index)), ) def reconcile_board_state( - repo_full_name: str, open_numbers: set[int], closed_numbers: set[int] + repo_full_name: str, + open_numbers: set[int], + closed_numbers: set[int], + workspace_id: int = 1, + connection_id: int = 1, ) -> dict[str, int]: """Reconcile local board state with the truth in Gitea. @@ -228,16 +425,18 @@ def reconcile_board_state( with get_db() as conn: rows = conn.execute( "SELECT issue_number, column_name FROM board_state " - "WHERE repo_full_name = ?", - (repo_full_name,), + "WHERE workspace_id = ? AND connection_id = ? " + "AND repo_full_name = ?", + (workspace_id, connection_id, repo_full_name), ).fetchall() for row in rows: number = row["issue_number"] if number not in known: conn.execute( "DELETE FROM board_state " - "WHERE repo_full_name = ? AND issue_number = ?", - (repo_full_name, number), + "WHERE workspace_id = ? AND connection_id = ? " + "AND repo_full_name = ? AND issue_number = ?", + (workspace_id, connection_id, repo_full_name, number), ) changed["purged"] += 1 changed["purged_numbers"].append(number) @@ -245,40 +444,69 @@ def reconcile_board_state( conn.execute( "UPDATE board_state SET column_name = 'done', " "updated_at = datetime('now') " - "WHERE repo_full_name = ? AND issue_number = ?", - (repo_full_name, number), + "WHERE workspace_id = ? AND connection_id = ? " + "AND repo_full_name = ? AND issue_number = ?", + (workspace_id, connection_id, repo_full_name, number), ) changed["closed_to_done"] += 1 changed["closed_numbers"].append(number) return changed -def get_picks(pick_date: str) -> list[dict]: +def get_picks( + pick_date: str, workspace_id: int = 1, connection_id: int | None = None +) -> list[dict]: """The day's focus picks (issue #5), in pinning order.""" with get_db() as conn: - rows = conn.execute( - "SELECT repo_full_name, issue_number FROM today_picks " - "WHERE pick_date = ? ORDER BY created_at, repo_full_name, issue_number", - (pick_date,), - ).fetchall() + if connection_id is None: + rows = conn.execute( + "SELECT connection_id, repo_full_name, issue_number " + "FROM today_picks WHERE pick_date = ? AND workspace_id = ? " + "ORDER BY created_at, repo_full_name, issue_number", + (pick_date, workspace_id), + ).fetchall() + else: + rows = conn.execute( + "SELECT connection_id, repo_full_name, issue_number " + "FROM today_picks WHERE pick_date = ? AND workspace_id = ? " + "AND connection_id = ? " + "ORDER BY created_at, repo_full_name, issue_number", + (pick_date, workspace_id, connection_id), + ).fetchall() return [dict(row) for row in rows] -def add_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None: +def add_pick( + pick_date: str, + repo_full_name: str, + issue_number: int, + workspace_id: int = 1, + connection_id: int = 1, +) -> None: with get_db() as conn: conn.execute( "INSERT OR IGNORE INTO today_picks " - "(pick_date, repo_full_name, issue_number) VALUES (?, ?, ?)", - (pick_date, repo_full_name, issue_number), + "(pick_date, workspace_id, connection_id, repo_full_name, " + "issue_number) VALUES (?, ?, ?, ?, ?)", + (pick_date, workspace_id, connection_id, repo_full_name, + issue_number), ) -def remove_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None: +def remove_pick( + pick_date: str, + repo_full_name: str, + issue_number: int, + workspace_id: int = 1, + connection_id: int = 1, +) -> None: with get_db() as conn: conn.execute( "DELETE FROM today_picks WHERE pick_date = ? " + "AND workspace_id = ? AND connection_id = ? " "AND repo_full_name = ? AND issue_number = ?", - (pick_date, repo_full_name, issue_number), + (pick_date, workspace_id, connection_id, repo_full_name, + issue_number), ) @@ -354,6 +582,8 @@ def log_event( to_column: str | None = None, source: str = "ui", created_at: str | None = None, + workspace_id: int = 1, + connection_id: int = 1, ) -> None: """Append one entry to the board's event journal. @@ -365,19 +595,21 @@ def log_event( with get_db() as conn: if created_at: conn.execute( - "INSERT INTO board_events (repo_full_name, issue_number, " - "event_type, from_column, to_column, source, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (repo_full_name, issue_number, event_type, - from_column, to_column, source, created_at), + "INSERT INTO board_events (workspace_id, connection_id, " + "repo_full_name, issue_number, event_type, from_column, " + "to_column, source, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (workspace_id, connection_id, repo_full_name, issue_number, + event_type, from_column, to_column, source, created_at), ) else: conn.execute( - "INSERT INTO board_events (repo_full_name, issue_number, " - "event_type, from_column, to_column, source) " - "VALUES (?, ?, ?, ?, ?, ?)", - (repo_full_name, issue_number, event_type, - from_column, to_column, source), + "INSERT INTO board_events (workspace_id, connection_id, " + "repo_full_name, issue_number, event_type, from_column, " + "to_column, source) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (workspace_id, connection_id, repo_full_name, issue_number, + event_type, from_column, to_column, source), ) @@ -400,7 +632,9 @@ def get_events( return [dict(row) for row in rows] -def column_entry_times(repo_full_name: str) -> dict[int, str]: +def column_entry_times( + repo_full_name: str, workspace_id: int = 1, connection_id: int = 1 +) -> dict[int, str]: """When each issue entered its current column, per the journal. Latest event per issue, comments excluded (a comment does not move @@ -410,9 +644,10 @@ def column_entry_times(repo_full_name: str) -> dict[int, str]: with get_db() as conn: rows = conn.execute( "SELECT issue_number, MAX(created_at) AS entered_at " - "FROM board_events WHERE repo_full_name = ? " + "FROM board_events WHERE workspace_id = ? AND connection_id = ? " + "AND repo_full_name = ? " "AND event_type != 'commented' GROUP BY issue_number", - (repo_full_name,), + (workspace_id, connection_id, repo_full_name), ).fetchall() return {row["issue_number"]: row["entered_at"] for row in rows} @@ -506,23 +741,32 @@ def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dic } -def set_notes(repo_full_name: str, note: str) -> None: +def set_notes( + repo_full_name: str, + note: str, + workspace_id: int = 1, + connection_id: int = 1, +) -> None: with get_db() as conn: conn.execute( - """INSERT INTO project_notes (repo_full_name, note) - VALUES (?, ?) - ON CONFLICT(repo_full_name) + """INSERT INTO project_notes + (workspace_id, connection_id, repo_full_name, note) + VALUES (?, ?, ?, ?) + ON CONFLICT(workspace_id, connection_id, repo_full_name) DO UPDATE SET note = excluded.note, updated_at = datetime('now')""", - (repo_full_name, note), + (workspace_id, connection_id, repo_full_name, note), ) -def get_notes(repo_full_name: str) -> str | None: +def get_notes( + repo_full_name: str, workspace_id: int = 1, connection_id: int = 1 +) -> str | None: with get_db() as conn: row = conn.execute( - "SELECT note FROM project_notes WHERE repo_full_name = ?", - (repo_full_name,), + "SELECT note FROM project_notes WHERE workspace_id = ? " + "AND connection_id = ? AND repo_full_name = ?", + (workspace_id, connection_id, repo_full_name), ).fetchone() return row["note"] if row else None @@ -544,3 +788,389 @@ def get_stats() -> dict: projects[name] = {col: 0 for col in KANBAN_COLUMNS} projects[name][row["column_name"]] = row["cnt"] return projects + + +# ── Connexions aux forges (0.24.0, P0) ──────────────────── + +def list_connections(include_disabled: bool = True) -> list[dict]: + query = "SELECT * FROM forge_connections" + if not include_disabled: + query += " WHERE enabled = 1" + query += " ORDER BY id" + with get_db() as conn: + return [dict(r) for r in conn.execute(query).fetchall()] + + +def get_connection(connection_id: int) -> dict | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM forge_connections WHERE id = ?", (connection_id,) + ).fetchone() + return dict(row) if row else None + + +def get_connection_by_slug(slug: str) -> dict | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM forge_connections WHERE slug = ?", (slug,) + ).fetchone() + return dict(row) if row else None + + +def create_connection( + slug: str, + kind: str, + name: str, + base_url: str, + token_encrypted: str = "", + account_login: str = "", + webhook_secret: str = "", + owner_user_id: int | None = None, +) -> int: + with get_db() as conn: + cur = conn.execute( + "INSERT INTO forge_connections (slug, kind, name, base_url, " + "token_encrypted, account_login, webhook_secret, owner_user_id) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (slug, kind, name, base_url, token_encrypted, account_login, + webhook_secret, owner_user_id), + ) + return int(cur.lastrowid) + + +def update_connection(connection_id: int, **fields) -> None: + allowed = { + "slug", "kind", "name", "base_url", "token_encrypted", + "account_login", "webhook_secret", "enabled", "owner_user_id", + } + items = {k: v for k, v in fields.items() if k in allowed} + if not items: + return + assignments = ", ".join(f"{k} = ?" for k in items) + with get_db() as conn: + conn.execute( + f"UPDATE forge_connections SET {assignments} WHERE id = ?", + (*items.values(), connection_id), + ) + + +def delete_connection(connection_id: int) -> None: + with get_db() as conn: + conn.execute( + "DELETE FROM forge_connections WHERE id = ?", (connection_id,) + ) + conn.execute( + "DELETE FROM workspace_connections WHERE connection_id = ?", + (connection_id,), + ) + conn.execute( + "DELETE FROM workspace_projects WHERE connection_id = ?", + (connection_id,), + ) + + +# ── Utilisateurs (0.24.0, P2) ────────────────────────────── + +def count_users() -> int: + with get_db() as conn: + return int(conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]) + + +def list_users() -> list[dict]: + with get_db() as conn: + return [ + dict(r) + for r in conn.execute("SELECT * FROM users ORDER BY id").fetchall() + ] + + +def get_user(user_id: int) -> dict | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM users WHERE id = ?", (user_id,) + ).fetchone() + return dict(row) if row else None + + +def get_user_by_username(username: str) -> dict | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM users WHERE username = ?", (username,) + ).fetchone() + return dict(row) if row else None + + +def get_user_by_draco(sub: str) -> dict | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM users WHERE draco_sub = ?", (sub,) + ).fetchone() + return dict(row) if row else None + + +def create_user( + username: str, + password_hash: str, + role: str = "member", + display_name: str = "", + must_change_password: int = 0, +) -> int: + with get_db() as conn: + cur = conn.execute( + "INSERT INTO users (username, password_hash, role, display_name, " + "must_change_password) VALUES (?, ?, ?, ?, ?)", + (username, password_hash, role, display_name, must_change_password), + ) + return int(cur.lastrowid) + + +def update_user(user_id: int, **fields) -> None: + allowed = { + "password_hash", "role", "display_name", "draco_sub", "active", + "must_change_password", "last_login_at", + } + items = {k: v for k, v in fields.items() if k in allowed} + if not items: + return + assignments = ", ".join(f"{k} = ?" for k in items) + with get_db() as conn: + conn.execute( + f"UPDATE users SET {assignments} WHERE id = ?", + (*items.values(), user_id), + ) + + +# ── Espaces de travail (0.24.0, P3) ──────────────────────── + +def list_workspaces() -> list[dict]: + with get_db() as conn: + return [ + dict(r) + for r in conn.execute( + "SELECT * FROM workspaces ORDER BY id" + ).fetchall() + ] + + +def get_workspace(workspace_id: int) -> dict | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM workspaces WHERE id = ?", (workspace_id,) + ).fetchone() + return dict(row) if row else None + + +def create_workspace(slug: str, name: str, color: str = "#7c5cfc") -> int: + with get_db() as conn: + cur = conn.execute( + "INSERT INTO workspaces (slug, name, color) VALUES (?, ?, ?)", + (slug, name, color), + ) + return int(cur.lastrowid) + + +def update_workspace(workspace_id: int, **fields) -> None: + allowed = {"slug", "name", "color"} + items = {k: v for k, v in fields.items() if k in allowed} + if not items: + return + assignments = ", ".join(f"{k} = ?" for k in items) + with get_db() as conn: + conn.execute( + f"UPDATE workspaces SET {assignments} WHERE id = ?", + (*items.values(), workspace_id), + ) + + +def delete_workspace(workspace_id: int) -> None: + with get_db() as conn: + for table in ( + "workspace_connections", "workspace_projects", + "workspace_members", "project_states", + ): + conn.execute( + f"DELETE FROM {table} WHERE workspace_id = ?", (workspace_id,) + ) + conn.execute("DELETE FROM workspaces WHERE id = ?", (workspace_id,)) + + +def workspace_connection_ids(workspace_id: int) -> list[int]: + with get_db() as conn: + rows = conn.execute( + "SELECT connection_id FROM workspace_connections " + "WHERE workspace_id = ? ORDER BY connection_id", + (workspace_id,), + ).fetchall() + return [r[0] for r in rows] + + +def set_workspace_connections(workspace_id: int, connection_ids: list[int]) -> None: + with get_db() as conn: + conn.execute( + "DELETE FROM workspace_connections WHERE workspace_id = ?", + (workspace_id,), + ) + conn.executemany( + "INSERT OR IGNORE INTO workspace_connections " + "(workspace_id, connection_id) VALUES (?, ?)", + [(workspace_id, cid) for cid in connection_ids], + ) + + +def workspace_project_selection(workspace_id: int) -> list[tuple[int, str]]: + """Explicit project inclusions; empty means "all projects".""" + with get_db() as conn: + rows = conn.execute( + "SELECT connection_id, repo_full_name FROM workspace_projects " + "WHERE workspace_id = ? ORDER BY connection_id, repo_full_name", + (workspace_id,), + ).fetchall() + return [(r[0], r[1]) for r in rows] + + +def set_workspace_projects( + workspace_id: int, selection: list[tuple[int, str]] +) -> None: + with get_db() as conn: + conn.execute( + "DELETE FROM workspace_projects WHERE workspace_id = ?", + (workspace_id,), + ) + conn.executemany( + "INSERT OR IGNORE INTO workspace_projects " + "(workspace_id, connection_id, repo_full_name) VALUES (?, ?, ?)", + [(workspace_id, cid, repo) for cid, repo in selection], + ) + + +def list_members(workspace_id: int) -> list[dict]: + with get_db() as conn: + rows = conn.execute( + "SELECT m.user_id, m.role, u.username, u.display_name " + "FROM workspace_members m JOIN users u ON u.id = m.user_id " + "WHERE m.workspace_id = ? ORDER BY u.username", + (workspace_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def set_member(workspace_id: int, user_id: int, role: str) -> None: + with get_db() as conn: + conn.execute( + "INSERT INTO workspace_members (workspace_id, user_id, role) " + "VALUES (?, ?, ?) ON CONFLICT(workspace_id, user_id) " + "DO UPDATE SET role = excluded.role", + (workspace_id, user_id, role), + ) + + +def remove_member(workspace_id: int, user_id: int) -> None: + with get_db() as conn: + conn.execute( + "DELETE FROM workspace_members WHERE workspace_id = ? " + "AND user_id = ?", + (workspace_id, user_id), + ) + + +def user_workspaces(user_id: int) -> list[dict]: + with get_db() as conn: + rows = conn.execute( + "SELECT w.*, m.role AS member_role FROM workspaces w " + "JOIN workspace_members m ON m.workspace_id = w.id " + "WHERE m.user_id = ? ORDER BY w.id", + (user_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def workspace_role(workspace_id: int, user_id: int) -> str | None: + with get_db() as conn: + row = conn.execute( + "SELECT role FROM workspace_members WHERE workspace_id = ? " + "AND user_id = ?", + (workspace_id, user_id), + ).fetchone() + return row[0] if row else None + + +# ── États de projets / archives locales (0.24.0, P4) ────── + +def get_project_state( + workspace_id: int, connection_id: int, repo_full_name: str +) -> dict | None: + with get_db() as conn: + row = conn.execute( + "SELECT * FROM project_states WHERE workspace_id = ? " + "AND connection_id = ? AND repo_full_name = ?", + (workspace_id, connection_id, repo_full_name), + ).fetchone() + return dict(row) if row else None + + +def set_local_archived( + workspace_id: int, + connection_id: int, + repo_full_name: str, + archived: bool, +) -> None: + with get_db() as conn: + conn.execute( + "INSERT INTO project_states (workspace_id, connection_id, " + "repo_full_name, local_archived, updated_at) " + "VALUES (?, ?, ?, ?, datetime('now')) " + "ON CONFLICT(workspace_id, connection_id, repo_full_name) " + "DO UPDATE SET local_archived = excluded.local_archived, " + "updated_at = excluded.updated_at", + (workspace_id, connection_id, repo_full_name, 1 if archived else 0), + ) + + +def list_local_archived(workspace_id: int) -> list[dict]: + with get_db() as conn: + rows = conn.execute( + "SELECT * FROM project_states WHERE workspace_id = ? " + "AND local_archived = 1 ORDER BY repo_full_name", + (workspace_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def purge_project_local_state( + workspace_id: int, connection_id: int, repo_full_name: str +) -> None: + """Drop every local trace of a project in one workspace.""" + with get_db() as conn: + for table in ("board_state", "project_notes", "today_picks"): + conn.execute( + f"DELETE FROM {table} WHERE workspace_id = ? " + "AND connection_id = ? AND repo_full_name = ?", + (workspace_id, connection_id, repo_full_name), + ) + conn.execute( + "DELETE FROM project_states WHERE workspace_id = ? " + "AND connection_id = ? AND repo_full_name = ?", + (workspace_id, connection_id, repo_full_name), + ) + + +# ── Journal d'administration (0.24.0, P2) ───────────────── + +def log_admin(user_id: int | None, action: str, details: str = "") -> None: + with get_db() as conn: + conn.execute( + "INSERT INTO admin_audit (user_id, action, details) " + "VALUES (?, ?, ?)", + (user_id, action, details), + ) + + +def list_admin_audit(limit: int = 100) -> list[dict]: + with get_db() as conn: + rows = conn.execute( + "SELECT a.*, u.username FROM admin_audit a " + "LEFT JOIN users u ON u.id = a.user_id " + "ORDER BY a.id DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] diff --git a/app/snapshots.py b/app/snapshots.py index c36aa0a..da1e24b 100644 --- a/app/snapshots.py +++ b/app/snapshots.py @@ -67,22 +67,27 @@ async def refresh_snapshots(gitea, classify_repos) -> int: return len(active) -async def snapshot_loop(gitea, classify_repos) -> None: +async def snapshot_loop(clients_provider, classify_repos) -> None: """Periodic refresher, started by the app lifespan. Sleeps briefly first: hydration already serves the pages, - the loop only has to keep the snapshots warm. + the loop only has to keep the snapshots warm. Since 0.24.0 + `clients_provider` is a zero-arg callable returning the live + forge clients (one per global connection); each is refreshed + in turn. The interval is re-read every cycle so the admin + settings panel can change it without a restart. """ - interval = get_settings().snapshot_interval - if interval <= 0: - logger.info("snapshot refresher disabled (interval <= 0)") - return await asyncio.sleep(10) while True: - try: - await refresh_snapshots(gitea, classify_repos) - except Exception as exc: - logger.warning("snapshot refresh cycle failed: %s", exc) + interval = get_settings().snapshot_interval + if interval <= 0: + logger.info("snapshot refresher disabled (interval <= 0)") + return + for client in clients_provider(): + try: + await refresh_snapshots(client, classify_repos) + except Exception as exc: + logger.warning("snapshot refresh cycle failed: %s", exc) await asyncio.sleep(interval) diff --git a/pyproject.toml b/pyproject.toml index 08ec87d..8e493d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kanbante" -version = "0.23.3" +version = "0.24.0" description = "Kanban project manager integrated with Gitea" requires-python = ">=3.11" dependencies = [ diff --git a/tests/conftest.py b/tests/conftest.py index 980b232..b249f49 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,8 +66,13 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di class FakeGitea: """Drop-in async replacement for GiteaClient used by the API tests.""" + kind = "gitea" + def __init__(self) -> None: now = datetime.now(timezone.utc) + self.connection_id = 1 + self.connection_slug = "gitea" + self.key_prefix = "" self.starred = set() self.repos = [ _repo("demo", now - timedelta(days=1)), @@ -136,9 +141,17 @@ class FakeGitea: results.append({**issue, "repository": {"full_name": full_name}}) return results + async def set_archived(self, owner, repo, archived): + for r in self.repos: + if r["full_name"] == f"{owner}/{repo}": + r["archived"] = archived + return {"archived": archived} + async def delete_label(self, owner, repo, label_id): labels = self.repo_labels.setdefault(f"{owner}/{repo}", {}) - for name in [n for n, l in labels.items() if l["id"] == label_id]: + for name in [ + n for n, l in labels.items() if str(l["id"]) == str(label_id) + ]: del labels[name] self.label_ops.append(("delete", f"{owner}/{repo}", label_id)) diff --git a/tests/test_github_client.py b/tests/test_github_client.py new file mode 100644 index 0000000..60998a2 --- /dev/null +++ b/tests/test_github_client.py @@ -0,0 +1,117 @@ +"""GitHubClient (0.24.0, P1): same protocol as GiteaClient, +GitHub wire shape underneath. Driven through MockTransport.""" + +import httpx +import pytest + +from app.github_client import GitHubClient + + +def make_client(handler) -> GitHubClient: + return GitHubClient( + base_url="https://api.github.com", + token="ghp_test", + cache_ttl=0, + transport=httpx.MockTransport(handler), + ) + + +async def test_auth_header_is_bearer(): + seen = {} + + def handler(request): + seen["auth"] = request.headers.get("authorization") + seen["accept"] = request.headers.get("accept") + return httpx.Response(200, json={"login": "octocat"}) + + client = make_client(handler) + user = await client.get_user() + assert user["login"] == "octocat" + assert seen["auth"] == "Bearer ghp_test" + assert "application/vnd.github+json" in seen["accept"] + + +async def test_pagination_follows_link_headers(): + def handler(request): + if "page=2" in str(request.url): + return httpx.Response(200, json=[{"full_name": "a/deux"}]) + return httpx.Response( + 200, + json=[{"full_name": "a/un"}], + headers={ + "Link": '; rel="next"' + }, + ) + + client = make_client(handler) + repos = await client._fetch_search_repos() + assert [r["full_name"] for r in repos] == ["a/un", "a/deux"] + + +async def test_labels_are_addressed_by_name(): + seen = {} + + def handler(request): + if request.method == "GET" and request.url.path.endswith("/labels"): + return httpx.Response( + 200, json=[{"id": 991, "name": "kanban:todo", "color": "3b82f6"}] + ) + if request.method == "POST": + seen["body"] = request.content.decode() + return httpx.Response(200, json=[]) + raise AssertionError(request.url) + + client = make_client(handler) + label = await client.ensure_label("a", "b", "kanban:todo", "3b82f6") + assert label["id"] == "kanban:todo" # name, not GitHub's numeric id + await client.add_issue_label("a", "b", 7, label["id"]) + assert '"kanban:todo"' in seen["body"] + + +async def test_search_issues_normalizes_repository(): + def handler(request): + assert "is%3Aissue" in str(request.url) or "is:issue" in str(request.url) + return httpx.Response( + 200, + json={ + "items": [ + { + "number": 3, + "title": "T", + "repository_url": "https://api.github.com/repos/a/b", + } + ] + }, + ) + + client = make_client(handler) + issues = await client.search_issues(q="mot", state="open") + assert issues[0]["repository"]["full_name"] == "a/b" + + +async def test_set_archived_uses_patch(): + seen = {} + + def handler(request): + seen["method"] = request.method + seen["body"] = request.content.decode() + return httpx.Response(200, json={"archived": True}) + + client = make_client(handler) + await client.set_archived("a", "b", True) + assert seen["method"] == "PATCH" + assert '"archived": true' in seen["body"] or '"archived":true' in seen["body"] + + +async def test_starred_roundtrip(): + seen = {} + + def handler(request): + seen["method"] = request.method + seen["path"] = request.url.path + return httpx.Response(204) + + client = make_client(handler) + await client.set_starred("a", "b", True) + assert seen["method"] == "PUT" + assert seen["path"] == "/user/starred/a/b"