feat(forge): fondation multi-forges — connexions chiffrées, registre, client GitHub (P0/P1)

- 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).
This commit is contained in:
Spark
2026-10-21 14:48:29 -04:00
parent f1a05279b6
commit 735d7cdfd0
11 changed files with 1403 additions and 79 deletions
+68
View File
@@ -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$<iterations>$<salt_b64>$<hash_b64>") 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)
+71 -2
View File
@@ -104,7 +104,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent 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: def _env_bool(name: str, default: bool) -> bool:
@@ -179,6 +179,7 @@ class Settings:
draco_secret_prev: str = "" draco_secret_prev: str = ""
sso_machine_key: str = "" sso_machine_key: str = ""
sso_url: str = "https://og.dracodev.net" sso_url: str = "https://og.dracodev.net"
archive_suggest_days: int = 180
@property @property
def auth_enabled(self) -> bool: def auth_enabled(self) -> bool:
@@ -186,7 +187,13 @@ class Settings:
def get_settings() -> 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( return Settings(
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"), gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
gitea_token=os.getenv("GITEA_TOKEN", ""), gitea_token=os.getenv("GITEA_TOKEN", ""),
@@ -230,4 +237,66 @@ def get_settings() -> Settings:
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""), draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""), sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"), 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
+161
View File
@@ -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
+70
View File
@@ -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 "—"
+48 -8
View File
@@ -38,7 +38,18 @@ MAX_PAGES = 40 # hard stop: 40 * 50 = 2000 items per list
class GiteaClient: 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<id>:").
"""
kind = "gitea"
api_suffix = "/api/v1"
auth_scheme = "token"
def __init__( def __init__(
self, self,
@@ -51,11 +62,15 @@ class GiteaClient:
self.base_url = (base_url or settings.gitea_url).rstrip("/") self.base_url = (base_url or settings.gitea_url).rstrip("/")
self.token = settings.gitea_token if token is None else token 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.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 = {"Accept": "application/json"}
headers.update(self._extra_headers())
if self.token: if self.token:
headers["Authorization"] = f"token {self.token}" headers["Authorization"] = f"{self.auth_scheme} {self.token}"
self.client = httpx.AsyncClient( self.client = httpx.AsyncClient(
base_url=f"{self.base_url}/api/v1", base_url=f"{self.base_url}{self.api_suffix}",
headers=headers, headers=headers,
timeout=15.0, timeout=15.0,
transport=transport, transport=transport,
@@ -69,6 +84,9 @@ class GiteaClient:
# label and the persisted `snapshots` table. # label and the persisted `snapshots` table.
self._snapshot_times: dict[str, str] = {} self._snapshot_times: dict[str, str] = {}
def _extra_headers(self) -> dict:
return {}
async def aclose(self) -> None: async def aclose(self) -> None:
await self.client.aclose() await self.client.aclose()
@@ -120,6 +138,9 @@ class GiteaClient:
# ── Snapshots (chantier B, 0.22.0) ────────────────────── # ── 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: def _persist_snapshot(self, key: str, value: Any) -> None:
"""Write-through to the SQLite snapshots table. """Write-through to the SQLite snapshots table.
@@ -129,7 +150,7 @@ class GiteaClient:
try: try:
from app import models from app import models
models.save_snapshot(key, value) models.save_snapshot(self._prefixed(key), value)
import datetime import datetime
self._snapshot_times[key] = datetime.datetime.now( self._snapshot_times[key] = datetime.datetime.now(
@@ -157,12 +178,20 @@ class GiteaClient:
logger.warning("snapshot hydration failed: %s", exc) logger.warning("snapshot hydration failed: %s", exc)
return 0 return 0
stale_at = time.monotonic() - self.cache_ttl - 1.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._cache[key] = (stale_at, payload)
self._snapshot_times[key] = updated_at self._snapshot_times[key] = updated_at
if snapshots: count += 1
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots)) if count:
return len(snapshots) logger.info("hydrated %d snapshot(s) from SQLite", count)
return count
def latest_snapshot_at(self) -> str | None: def latest_snapshot_at(self) -> str | None:
"""Freshest Gitea fetch time across tracked keys (UTC str).""" """Freshest Gitea fetch time across tracked keys (UTC str)."""
@@ -487,6 +516,17 @@ class GiteaClient:
self._cache_clear() self._cache_clear()
return r.json() 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( async def create_branch(
self, owner: str, repo: str, new_branch: str, old_branch: str self, owner: str, repo: str, new_branch: str, old_branch: str
) -> dict: ) -> dict:
+151
View File
@@ -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()
+687 -57
View File
@@ -49,25 +49,31 @@ def init_db() -> None:
""" """
CREATE TABLE IF NOT EXISTS board_state ( CREATE TABLE IF NOT EXISTS board_state (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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, repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL, issue_number INTEGER NOT NULL,
column_name TEXT NOT NULL DEFAULT 'backlog', column_name TEXT NOT NULL DEFAULT 'backlog',
position REAL NOT NULL DEFAULT 0, position REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')), 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 ( CREATE TABLE IF NOT EXISTS project_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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, repo_full_name TEXT NOT NULL,
note TEXT NOT NULL, note TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_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 ( CREATE TABLE IF NOT EXISTS board_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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, repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL, issue_number INTEGER NOT NULL,
event_type TEXT NOT NULL, event_type TEXT NOT NULL,
@@ -82,10 +88,12 @@ def init_db() -> None:
CREATE TABLE IF NOT EXISTS today_picks ( CREATE TABLE IF NOT EXISTS today_picks (
pick_date TEXT NOT NULL, 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, repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL, issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')), 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 ( CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -98,6 +106,79 @@ def init_db() -> None:
updated_at TEXT NOT NULL DEFAULT (datetime('now')) 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 CREATE INDEX IF NOT EXISTS idx_events_repo
ON board_events(repo_full_name, issue_number, created_at); 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 " "UPDATE project_notes SET updated_at = created_at "
"WHERE updated_at IS NULL" "WHERE updated_at IS NULL"
) )
_migrate_v24(conn)
# Rows written under a column name that no longer exists used to crash # Rows written under a column name that no longer exists used to crash
# get_board_state() with a KeyError. Drop them once, here. # get_board_state() with a KeyError. Drop them once, here.
placeholders = ",".join("?" for _ in KANBAN_COLUMNS) 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: def ping_db() -> bool:
"""Cheap check used by /api/readiness.""" """Cheap check used by /api/readiness."""
try: try:
@@ -140,14 +315,17 @@ def ping_db() -> bool:
return False 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.""" """Return board columns with their issues, ordered by position."""
with get_db() as conn: with get_db() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT issue_number, column_name, position, updated_at " "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", "ORDER BY position ASC, issue_number ASC",
(repo_full_name,), (workspace_id, connection_id, repo_full_name),
).fetchall() ).fetchall()
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS} 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 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).""" """Move an issue to a different column (appended at the end)."""
if to_column not in KANBAN_COLUMNS: if to_column not in KANBAN_COLUMNS:
raise ValueError(f"Invalid column: {to_column}") raise ValueError(f"Invalid column: {to_column}")
with get_db() as conn: with get_db() as conn:
row = conn.execute( row = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 AS next_pos FROM board_state " "SELECT COALESCE(MAX(position), -1) + 1 AS next_pos FROM board_state "
"WHERE repo_full_name = ? AND column_name = ?", "WHERE workspace_id = ? AND connection_id = ? "
(repo_full_name, to_column), "AND repo_full_name = ? AND column_name = ?",
(workspace_id, connection_id, repo_full_name, to_column),
).fetchone() ).fetchone()
conn.execute( conn.execute(
"""INSERT INTO board_state """INSERT INTO board_state
(repo_full_name, issue_number, column_name, position, updated_at) (workspace_id, connection_id, repo_full_name, issue_number,
VALUES (?, ?, ?, ?, datetime('now')) column_name, position, updated_at)
ON CONFLICT(repo_full_name, issue_number) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(workspace_id, connection_id, repo_full_name, issue_number)
DO UPDATE SET column_name = excluded.column_name, DO UPDATE SET column_name = excluded.column_name,
position = excluded.position, position = excluded.position,
updated_at = excluded.updated_at""", 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( 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: ) -> None:
"""Persist the exact order of the cards in one column. """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): for index, issue_number in enumerate(ordered_issue_numbers):
conn.execute( conn.execute(
"""INSERT INTO board_state """INSERT INTO board_state
(repo_full_name, issue_number, column_name, position, updated_at) (workspace_id, connection_id, repo_full_name,
VALUES (?, ?, ?, ?, datetime('now')) issue_number, column_name, position, updated_at)
ON CONFLICT(repo_full_name, issue_number) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(workspace_id, connection_id, repo_full_name, issue_number)
DO UPDATE SET column_name = excluded.column_name, DO UPDATE SET column_name = excluded.column_name,
position = excluded.position, position = excluded.position,
updated_at = excluded.updated_at""", 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( 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]: ) -> dict[str, int]:
"""Reconcile local board state with the truth in Gitea. """Reconcile local board state with the truth in Gitea.
@@ -228,16 +425,18 @@ def reconcile_board_state(
with get_db() as conn: with get_db() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT issue_number, column_name FROM board_state " "SELECT issue_number, column_name FROM board_state "
"WHERE repo_full_name = ?", "WHERE workspace_id = ? AND connection_id = ? "
(repo_full_name,), "AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
).fetchall() ).fetchall()
for row in rows: for row in rows:
number = row["issue_number"] number = row["issue_number"]
if number not in known: if number not in known:
conn.execute( conn.execute(
"DELETE FROM board_state " "DELETE FROM board_state "
"WHERE repo_full_name = ? AND issue_number = ?", "WHERE workspace_id = ? AND connection_id = ? "
(repo_full_name, number), "AND repo_full_name = ? AND issue_number = ?",
(workspace_id, connection_id, repo_full_name, number),
) )
changed["purged"] += 1 changed["purged"] += 1
changed["purged_numbers"].append(number) changed["purged_numbers"].append(number)
@@ -245,40 +444,69 @@ def reconcile_board_state(
conn.execute( conn.execute(
"UPDATE board_state SET column_name = 'done', " "UPDATE board_state SET column_name = 'done', "
"updated_at = datetime('now') " "updated_at = datetime('now') "
"WHERE repo_full_name = ? AND issue_number = ?", "WHERE workspace_id = ? AND connection_id = ? "
(repo_full_name, number), "AND repo_full_name = ? AND issue_number = ?",
(workspace_id, connection_id, repo_full_name, number),
) )
changed["closed_to_done"] += 1 changed["closed_to_done"] += 1
changed["closed_numbers"].append(number) changed["closed_numbers"].append(number)
return changed 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.""" """The day's focus picks (issue #5), in pinning order."""
with get_db() as conn: with get_db() as conn:
rows = conn.execute( if connection_id is None:
"SELECT repo_full_name, issue_number FROM today_picks " rows = conn.execute(
"WHERE pick_date = ? ORDER BY created_at, repo_full_name, issue_number", "SELECT connection_id, repo_full_name, issue_number "
(pick_date,), "FROM today_picks WHERE pick_date = ? AND workspace_id = ? "
).fetchall() "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] 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: with get_db() as conn:
conn.execute( conn.execute(
"INSERT OR IGNORE INTO today_picks " "INSERT OR IGNORE INTO today_picks "
"(pick_date, repo_full_name, issue_number) VALUES (?, ?, ?)", "(pick_date, workspace_id, connection_id, repo_full_name, "
(pick_date, repo_full_name, issue_number), "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: with get_db() as conn:
conn.execute( conn.execute(
"DELETE FROM today_picks WHERE pick_date = ? " "DELETE FROM today_picks WHERE pick_date = ? "
"AND workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND issue_number = ?", "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, to_column: str | None = None,
source: str = "ui", source: str = "ui",
created_at: str | None = None, created_at: str | None = None,
workspace_id: int = 1,
connection_id: int = 1,
) -> None: ) -> None:
"""Append one entry to the board's event journal. """Append one entry to the board's event journal.
@@ -365,19 +595,21 @@ def log_event(
with get_db() as conn: with get_db() as conn:
if created_at: if created_at:
conn.execute( conn.execute(
"INSERT INTO board_events (repo_full_name, issue_number, " "INSERT INTO board_events (workspace_id, connection_id, "
"event_type, from_column, to_column, source, created_at) " "repo_full_name, issue_number, event_type, from_column, "
"VALUES (?, ?, ?, ?, ?, ?, ?)", "to_column, source, created_at) "
(repo_full_name, issue_number, event_type, "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
from_column, to_column, source, created_at), (workspace_id, connection_id, repo_full_name, issue_number,
event_type, from_column, to_column, source, created_at),
) )
else: else:
conn.execute( conn.execute(
"INSERT INTO board_events (repo_full_name, issue_number, " "INSERT INTO board_events (workspace_id, connection_id, "
"event_type, from_column, to_column, source) " "repo_full_name, issue_number, event_type, from_column, "
"VALUES (?, ?, ?, ?, ?, ?)", "to_column, source) "
(repo_full_name, issue_number, event_type, "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
from_column, to_column, source), (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] 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. """When each issue entered its current column, per the journal.
Latest event per issue, comments excluded (a comment does not move 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: with get_db() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT issue_number, MAX(created_at) AS entered_at " "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", "AND event_type != 'commented' GROUP BY issue_number",
(repo_full_name,), (workspace_id, connection_id, repo_full_name),
).fetchall() ).fetchall()
return {row["issue_number"]: row["entered_at"] for row in rows} 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: with get_db() as conn:
conn.execute( conn.execute(
"""INSERT INTO project_notes (repo_full_name, note) """INSERT INTO project_notes
VALUES (?, ?) (workspace_id, connection_id, repo_full_name, note)
ON CONFLICT(repo_full_name) VALUES (?, ?, ?, ?)
ON CONFLICT(workspace_id, connection_id, repo_full_name)
DO UPDATE SET note = excluded.note, DO UPDATE SET note = excluded.note,
updated_at = datetime('now')""", 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: with get_db() as conn:
row = conn.execute( row = conn.execute(
"SELECT note FROM project_notes WHERE repo_full_name = ?", "SELECT note FROM project_notes WHERE workspace_id = ? "
(repo_full_name,), "AND connection_id = ? AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
).fetchone() ).fetchone()
return row["note"] if row else None 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] = {col: 0 for col in KANBAN_COLUMNS}
projects[name][row["column_name"]] = row["cnt"] projects[name][row["column_name"]] = row["cnt"]
return projects 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]
+15 -10
View File
@@ -67,22 +67,27 @@ async def refresh_snapshots(gitea, classify_repos) -> int:
return len(active) 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. """Periodic refresher, started by the app lifespan.
Sleeps briefly first: hydration already serves the pages, 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) await asyncio.sleep(10)
while True: while True:
try: interval = get_settings().snapshot_interval
await refresh_snapshots(gitea, classify_repos) if interval <= 0:
except Exception as exc: logger.info("snapshot refresher disabled (interval <= 0)")
logger.warning("snapshot refresh cycle failed: %s", exc) 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) await asyncio.sleep(interval)
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kanbante" name = "kanbante"
version = "0.23.3" version = "0.24.0"
description = "Kanban project manager integrated with Gitea" description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+14 -1
View File
@@ -66,8 +66,13 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
class FakeGitea: class FakeGitea:
"""Drop-in async replacement for GiteaClient used by the API tests.""" """Drop-in async replacement for GiteaClient used by the API tests."""
kind = "gitea"
def __init__(self) -> None: def __init__(self) -> None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
self.connection_id = 1
self.connection_slug = "gitea"
self.key_prefix = ""
self.starred = set() self.starred = set()
self.repos = [ self.repos = [
_repo("demo", now - timedelta(days=1)), _repo("demo", now - timedelta(days=1)),
@@ -136,9 +141,17 @@ class FakeGitea:
results.append({**issue, "repository": {"full_name": full_name}}) results.append({**issue, "repository": {"full_name": full_name}})
return results 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): async def delete_label(self, owner, repo, label_id):
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {}) 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] del labels[name]
self.label_ops.append(("delete", f"{owner}/{repo}", label_id)) self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
+117
View File
@@ -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": '<https://api.github.com/user/repos?page=2>; 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"