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
+9 -2
View File
@@ -57,7 +57,14 @@ def _file_response(content: str, commit_sha: str = SHA) -> httpx.Response:
def _patched(forge):
return patch("scribe.services.forge.get_forge", AsyncMock(return_value=forge))
"""Stub the owner-keyring lookup (#2778): None → an empty selector, i.e.
the owner has no connections — the old 'no forge configured' state."""
from scribe.services.forge import ForgeSelector
selector = ForgeSelector(() if forge is None else (forge,))
return patch(
"scribe.services.forge.get_forges", AsyncMock(return_value=selector)
)
async def test_no_forge_attaches_nothing():
@@ -260,7 +267,7 @@ def test_both_pull_surfaces_attach_freshness():
async def test_forge_failure_inside_lookup_never_breaks_the_pull():
with patch(
"scribe.services.forge.get_forge", AsyncMock(side_effect=RuntimeError("cfg"))
"scribe.services.forge.get_forges", AsyncMock(side_effect=RuntimeError("cfg"))
):
data = _data()
await svc.attach_live_body(_note(), data)