From 1faf8f3ececad4e9c1eb4678dc8a00a7d02e7ddc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 19 Aug 2026 11:23:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(forge):=20per-user=20forge=20connections?= =?UTF-8?q?=20=E2=80=94=20keyring,=20host-keyed=20resolution,=20project=20?= =?UTF-8?q?pin=20(#2778)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A forge token is a user's credential, not an instance's. The single admin-settings config is replaced by per-user keyring rows (one per forge host), and every server-side forge read runs on the PROJECT OWNER's keyring: - forge_connections table + projects.forge_connection_id pin (migration 0078, which also carries the existing admin config into the first admin's row and deletes the old setting keys — no legacy dual-read) - get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector; resolve(repo) picks the connection whose host serves the repo. A pinned project uses ONLY its pinned connection; a stale pin (ownership moved) is ignored, never honored across users - env FORGE_* config survives as an implicit entry for admin owners only; a stored row for the same host beats it - consumers threaded: pull-time freshness (owner of the note), coverage (owner of the project), coverage routes' configured flag - routes: /api/settings/forge-connections CRUD + per-connection test (own-rows only, tokens never returned); /api/admin/forge shrinks to /api/admin/forge-webhook (secret only); PUT /api/projects//forge pins, owner-or-admin asking, owner's connections only - UI: Git Forges card moves to Settings -> Integrations as a connection list; webhook secret stays in the admin Config tab; owner-only forge select on the project coverage card - backups exclude forge_connections (credentials, api_keys precedent) and the pin, so restores fall back to keyring resolution Co-Authored-By: Claude Fable 5 --- alembic/versions/0078_forge_connections.py | 130 +++++++++++ frontend/src/views/ProjectView.vue | 78 ++++++- frontend/src/views/SettingsView.vue | 255 ++++++++++++++------- src/scribe/config.py | 11 +- src/scribe/models/__init__.py | 1 + src/scribe/models/forge_connection.py | 45 ++++ src/scribe/models/project.py | 10 + src/scribe/routes/admin.py | 78 ++----- src/scribe/routes/projects.py | 56 ++++- src/scribe/routes/settings.py | 103 ++++++++- src/scribe/services/backup.py | 6 + src/scribe/services/coverage.py | 29 ++- src/scribe/services/forge.py | 164 +++++++++---- src/scribe/services/forge_connections.py | 194 ++++++++++++++++ src/scribe/services/snippets.py | 31 +-- tests/test_integration_forge_keyring.py | 191 +++++++++++++++ tests/test_pattern_coverage.py | 18 +- tests/test_services_forge.py | 151 ++++++------ tests/test_snippet_live_body.py | 11 +- 19 files changed, 1252 insertions(+), 310 deletions(-) create mode 100644 alembic/versions/0078_forge_connections.py create mode 100644 src/scribe/models/forge_connection.py create mode 100644 src/scribe/services/forge_connections.py create mode 100644 tests/test_integration_forge_keyring.py 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 @@