feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
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>
This commit is contained in:
2026-08-19 11:23:22 -04:00
co-authored by Claude Fable 5
parent 7a5e2b18d9
commit 1faf8f3ece
19 changed files with 1252 additions and 310 deletions
+13 -5
View File
@@ -194,6 +194,14 @@ def _forge(tar_bytes: bytes):
)
def _selector(tar_bytes: bytes):
"""The keyring shape compute_coverage consumes since #2778 — one owner
keyring holding the mocked Gitea adapter."""
from scribe.services.forge import ForgeSelector
return ForgeSelector((_forge(tar_bytes),))
@pytest_asyncio.fixture
async def _dispose_engine():
from scribe.models import engine
@@ -250,9 +258,9 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
)
uid, pid = seeded["uid"], seeded["pid"]
forge = _forge(_tarball(TREE))
selector = _selector(_tarball(TREE))
coverage = await compute_coverage(uid, pid, forge=forge)
coverage = await compute_coverage(uid, pid, selector=selector)
assert coverage is not None
assert coverage["total"] == 4
assert coverage["recorded"] == 2
@@ -266,7 +274,7 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
# Nothing computed → nothing cached; refresh writes; the cache reads back
# byte-equal, because enter_project will serve exactly this.
assert await cached_coverage(uid, pid) is None
stored = await refresh_coverage(uid, pid, forge=forge)
stored = await refresh_coverage(uid, pid, selector=selector)
assert (await cached_coverage(uid, pid)) == json.loads(json.dumps(stored))
@@ -284,7 +292,7 @@ async def test_enter_project_surfaces_the_line_only_once_computed(seeded):
before = await enter_project(project_id=pid)
assert before["pattern_coverage"] is None
await refresh_coverage(uid, pid, forge=_forge(_tarball(TREE)))
await refresh_coverage(uid, pid, selector=_selector(_tarball(TREE)))
after = await enter_project(project_id=pid)
line = after["pattern_coverage"]
assert line.startswith(
@@ -314,4 +322,4 @@ async def test_unservable_binding_measures_nothing(seeded):
await s.commit()
await set_binding(uid, "https://github.com/somebody/else.git", other_pid)
assert await compute_coverage(uid, other_pid, forge=_forge(_tarball(TREE))) is None
assert await compute_coverage(uid, other_pid, selector=_selector(_tarball(TREE))) is None