diff --git a/alembic/versions/0078_forge_connections.py b/alembic/versions/0078_forge_connections.py new file mode 100644 index 0000000..da9b780 --- /dev/null +++ b/alembic/versions/0078_forge_connections.py @@ -0,0 +1,130 @@ +"""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") diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 7c4c832..0076373 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -1,7 +1,8 @@