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
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:
@@ -1,7 +1,7 @@
|
||||
"""Project management routes."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from scribe.auth import login_required, get_current_user_id
|
||||
from scribe.routes.utils import not_found, parse_pagination
|
||||
@@ -130,7 +130,7 @@ async def get_coverage_route(project_id: int):
|
||||
push or an explicit refresh).
|
||||
"""
|
||||
from scribe.services.coverage import cached_coverage
|
||||
from scribe.services.forge import get_forge
|
||||
from scribe.services.forge import get_forges
|
||||
|
||||
uid = get_current_user_id()
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
@@ -139,7 +139,9 @@ async def get_coverage_route(project_id: int):
|
||||
project, _ = result
|
||||
owner_uid = project.user_id or uid
|
||||
return jsonify({
|
||||
"configured": await get_forge() is not None,
|
||||
# The OWNER's keyring (#2778) — whether refresh could do anything,
|
||||
# regardless of who is looking.
|
||||
"configured": (await get_forges(owner_uid, project_id)).configured,
|
||||
"coverage": await cached_coverage(owner_uid, project_id),
|
||||
})
|
||||
|
||||
@@ -153,7 +155,7 @@ async def refresh_coverage_route(project_id: int):
|
||||
and wants the new number, and the forge timeout bounds the wait.
|
||||
"""
|
||||
from scribe.services.coverage import refresh_coverage
|
||||
from scribe.services.forge import ForgeError, get_forge
|
||||
from scribe.services.forge import ForgeError, get_forges
|
||||
|
||||
uid = get_current_user_id()
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
@@ -161,20 +163,56 @@ async def refresh_coverage_route(project_id: int):
|
||||
return not_found("Project")
|
||||
project, _ = result
|
||||
owner_uid = project.user_id or uid
|
||||
if await get_forge() is None:
|
||||
return jsonify({"error": "No git forge is configured (Settings → Config → Git Forge)"}), 400
|
||||
selector = await get_forges(owner_uid, project_id)
|
||||
if not selector.configured:
|
||||
return jsonify({
|
||||
"error": "The project owner has no forge connection "
|
||||
"(Settings → Git forges)"
|
||||
}), 400
|
||||
try:
|
||||
coverage = await refresh_coverage(owner_uid, project_id)
|
||||
coverage = await refresh_coverage(owner_uid, project_id, selector=selector)
|
||||
except ForgeError as exc:
|
||||
return jsonify({"error": str(exc)}), 502
|
||||
if coverage is None:
|
||||
return jsonify({
|
||||
"error": "No bound repo is served by the configured forge — "
|
||||
"bind the project's repo (bind_repo) on a remote the forge hosts"
|
||||
"error": "No bound repo is served by the owner's forge connections — "
|
||||
"bind the project's repo (bind_repo) on a remote a connection hosts"
|
||||
}), 400
|
||||
return jsonify({"coverage": coverage})
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>/forge", methods=["PUT"])
|
||||
@login_required
|
||||
async def set_project_forge_route(project_id: int):
|
||||
"""Pin the project to one forge connection, or clear the pin (#2778).
|
||||
|
||||
Body: {"connection_id": <id> | null}. Owner-or-admin may ask; either way
|
||||
the pin can only reference a connection the project OWNER holds — the
|
||||
service enforces that, so a collaborator's token can never end up serving
|
||||
someone else's project.
|
||||
"""
|
||||
from scribe.services.forge_connections import set_project_pin
|
||||
|
||||
uid = get_current_user_id()
|
||||
result = await get_project_for_user(uid, project_id)
|
||||
if result is None:
|
||||
return not_found("Project")
|
||||
project, permission = result
|
||||
if permission != "owner" and g.user.role != "admin":
|
||||
return jsonify({"error": "Only the project owner can change its forge"}), 403
|
||||
|
||||
data = await request.get_json() or {}
|
||||
raw = data.get("connection_id")
|
||||
if raw is not None and not isinstance(raw, int):
|
||||
return jsonify({"error": "connection_id must be an integer or null"}), 400
|
||||
owner_uid = project.user_id or uid
|
||||
if not await set_project_pin(owner_uid, project_id, raw):
|
||||
return jsonify({
|
||||
"error": "That connection does not belong to the project owner"
|
||||
}), 400
|
||||
return jsonify({"forge_connection_id": raw})
|
||||
|
||||
|
||||
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
|
||||
@login_required
|
||||
async def get_project_notes_route(project_id: int):
|
||||
|
||||
Reference in New Issue
Block a user