"""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