CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
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/<id>/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 <noreply@anthropic.com>
131 lines
4.8 KiB
Python
131 lines
4.8 KiB
Python
"""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")
|