"""Forge connections move to the user level (#2778) Revision ID: 0078 Revises: 0077 Create Date: 2026-08-19 A forge token is a user's credential, not an instance's: the single admin-settings config meant every user's snippet-freshness and coverage reads ran under the operator's token. Each user now owns a keyring of connections — one per forge host — and projects resolve forge reads on their OWNER's keyring, with an optional per-project pin (projects.forge_connection_id). The data move carries the existing admin config into a connection row for the first admin user (host parsed from the base URL), then deletes the old setting keys outright — no legacy dual-read (rule #22). The env-var channel (FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it survives as an implicit keyring entry for admin users only. """ from urllib.parse import urlsplit import sqlalchemy as sa from alembic import op revision = "0078" down_revision = "0077" branch_labels = None depends_on = None _SETTING_KEYS = ("forge_kind", "forge_base_url", "forge_token") def upgrade() -> None: op.create_table( "forge_connections", sa.Column("id", sa.Integer(), primary_key=True), sa.Column( "user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, ), sa.Column("kind", sa.Text(), nullable=False), sa.Column("base_url", sa.Text(), nullable=False), sa.Column("host", sa.Text(), nullable=False), sa.Column("token", sa.Text(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"), ) op.add_column( "projects", sa.Column( "forge_connection_id", sa.BigInteger(), sa.ForeignKey( "forge_connections.id", ondelete="SET NULL", name="fk_projects_forge_connection_id", ), nullable=True, ), ) # Data move: the admin-settings config becomes the first admin's keyring # row. All three values must be present — a partial config never produced # an adapter, so carrying it over would invent a connection that never # worked. conn = op.get_bind() row = conn.execute( sa.text( "SELECT s.key, s.value FROM settings s" " JOIN users u ON u.id = s.user_id" " WHERE u.role = 'admin' AND s.key IN :keys" " AND s.user_id = (" " SELECT MIN(id) FROM users WHERE role = 'admin'" " )" ).bindparams(sa.bindparam("keys", expanding=True)), {"keys": list(_SETTING_KEYS)}, ).fetchall() values = {key: (value or "").strip() for key, value in row} kind = values.get("forge_kind", "").lower() base_url = values.get("forge_base_url", "").rstrip("/") token = values.get("forge_token", "") host = (urlsplit(base_url).hostname or "").lower() if kind and base_url and token and host: conn.execute( sa.text( "INSERT INTO forge_connections" " (user_id, kind, base_url, host, token, created_at, updated_at)" " SELECT MIN(id), :kind, :base_url, :host, :token, NOW(), NOW()" " FROM users WHERE role = 'admin'" ), {"kind": kind, "base_url": base_url, "host": host, "token": token}, ) conn.execute( sa.text( "DELETE FROM settings WHERE key IN :keys" ).bindparams(sa.bindparam("keys", expanding=True)), {"keys": list(_SETTING_KEYS)}, ) def downgrade() -> None: # Reverse data move: the first admin's row (if any) becomes the admin # settings again. Other users' rows have no pre-0078 representation and # are dropped with the table. conn = op.get_bind() row = conn.execute( sa.text( "SELECT user_id, kind, base_url, token FROM forge_connections" " WHERE user_id = (SELECT MIN(id) FROM users WHERE role = 'admin')" " ORDER BY id LIMIT 1" ) ).fetchone() if row is not None: for key, value in ( ("forge_kind", row.kind), ("forge_base_url", row.base_url), ("forge_token", row.token), ): conn.execute( sa.text( "INSERT INTO settings (user_id, key, value)" " VALUES (:uid, :key, :value)" " ON CONFLICT (user_id, key) DO UPDATE SET value = :value" ), {"uid": row.user_id, "key": key, "value": value}, ) op.drop_column("projects", "forge_connection_id") op.drop_table("forge_connections")