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>
482 lines
20 KiB
Python
482 lines
20 KiB
Python
"""Forge adapter — optional server-side READ access to the operator's git forge.
|
|
|
|
Step 4 of milestone 288 (#2689, decision #2686). The recorded location of a
|
|
snippet is the source of truth for its code and the stored body is a cache;
|
|
this module is the seam that lets the SERVER read that source of truth, so the
|
|
cache can be refreshed at pull time (step 5), drift can be flagged from push
|
|
webhooks (step 6), and coverage can be measured (step 7).
|
|
|
|
Design constraints, in force everywhere below:
|
|
|
|
- OPTIONAL per user (rule #115, sharpened by #2778). Connections are
|
|
per-user keyring rows resolved by repo host on the PROJECT OWNER's
|
|
keyring; `get_forges()` returns an empty selector when the owner has
|
|
nothing configured, and every consumer must treat that as "keep today's
|
|
behavior". A user who never configures a forge is not degraded — that is
|
|
the baseline.
|
|
- READ-ONLY by construction. The adapter exposes reads; there is no write
|
|
method to misuse. The token an operator mints for it only ever needs read
|
|
scope, and the docs say so.
|
|
- The contract stays as small as its consumers (steps 5-7): read_file /
|
|
latest_commit / archive / default_branch / resolve_repo / check. Two
|
|
implementations (Gitea, GitHub — step 8) keep it honest; resist widening
|
|
it speculatively.
|
|
- Repo identity is the repo-binding key — `normalize_repo_key`'s
|
|
host/owner/repo — so the join between a snippet's recorded repo and the
|
|
forge needs no new identity scheme. The host segment selects whether THIS
|
|
forge can serve the repo; the remainder is the API path.
|
|
- Errors carry no token, ever, and failures are exceptions the caller
|
|
handles — a consumer decides whether to fall back (pull-time fetch) or
|
|
surface (settings test button); this module never silently swallows.
|
|
|
|
This is also the codebase's first outbound-HTTP client with a real timeout
|
|
convention (oauth.py predates it): short total timeout, no retries — every
|
|
consumer has a fallback, so a slow forge must cost bounded time.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from urllib.parse import quote, urlsplit
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
|
|
from scribe.config import Config
|
|
from scribe.services.repo_bindings import normalize_repo_key
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Kinds a connection can use. Matches _FORGE_CLASSES below.
|
|
FORGE_KINDS = ("gitea", "github")
|
|
|
|
# Total budget per forge call. Consumers either have a cache to fall back to
|
|
# (step 5) or a user watching a button (the test probe) — neither tolerates a
|
|
# hung socket, and there is no retry: the fallback IS the retry policy.
|
|
_TIMEOUT = httpx.Timeout(5.0)
|
|
|
|
# Archive downloads move a whole-repo tarball and only ever run off the
|
|
# request path (coverage recompute, step 7), so they get a bigger budget than
|
|
# the per-file reads — but still a bound, because a hung background task
|
|
# holds a connection slot as surely as a foreground one.
|
|
_ARCHIVE_TIMEOUT = httpx.Timeout(60.0)
|
|
|
|
|
|
class ForgeError(RuntimeError):
|
|
"""A forge call failed (network, auth, unexpected payload). Token-free."""
|
|
|
|
|
|
class ForgeNotFound(ForgeError):
|
|
"""The repo, path, or ref does not exist on the forge — the one failure
|
|
consumers treat differently, because for a recorded snippet location it is
|
|
itself a finding (the recorded path is gone)."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ForgeFile:
|
|
"""One file read from the forge at a specific point in history."""
|
|
|
|
content: str
|
|
# The commit the content was served at — what provenance stores (#2688).
|
|
commit_sha: str
|
|
path: str
|
|
|
|
|
|
def host_of(url: str) -> str:
|
|
"""The lowercase hostname of a URL — the keyring's lookup key (#2778)."""
|
|
return (urlsplit(url).hostname or "").lower()
|
|
|
|
|
|
class ForgeAdapter:
|
|
"""The shared plumbing of the forge contract; adapters supply the API
|
|
base, auth headers, and any endpoint that differs.
|
|
|
|
`transport` exists for tests: httpx.MockTransport makes the contract
|
|
testable without a live server or a new dependency. Production callers
|
|
never pass it.
|
|
"""
|
|
|
|
kind = ""
|
|
# One "newest commit for this path" page — the endpoint is shared but the
|
|
# page-size parameter is not, so each adapter names its own.
|
|
_commit_page_params: dict = {}
|
|
|
|
def __init__(self, base_url: str, token: str, *, transport=None) -> None:
|
|
self.base_url = (base_url or "").rstrip("/")
|
|
self._token = token or ""
|
|
self._transport = transport
|
|
|
|
@property
|
|
def host(self) -> str:
|
|
return host_of(self.base_url)
|
|
|
|
def resolve_repo(self, repo_or_url: str) -> str | None:
|
|
"""The forge-API repo path for a recorded repo — or None if this forge
|
|
does not serve it.
|
|
|
|
Accepts anything `normalize_repo_key` accepts (a raw remote URL or an
|
|
already-normalized key). None is a NORMAL answer, not an error: a
|
|
snippet recorded against github.com on an instance whose forge is a
|
|
self-hosted Gitea is simply out of this forge's reach.
|
|
"""
|
|
key = normalize_repo_key(repo_or_url or "")
|
|
if not key or "/" not in key:
|
|
return None
|
|
host, _, rest = key.partition("/")
|
|
if host != self.host or "/" not in rest:
|
|
return None
|
|
return rest
|
|
|
|
def _api_base(self) -> str:
|
|
raise NotImplementedError
|
|
|
|
def _headers(self) -> dict:
|
|
raise NotImplementedError
|
|
|
|
def _client(self) -> httpx.AsyncClient:
|
|
kwargs: dict = {
|
|
"base_url": self._api_base(),
|
|
"headers": self._headers(),
|
|
"timeout": _TIMEOUT,
|
|
# GitHub serves tarballs via a 302 to codeload. httpx drops the
|
|
# Authorization header on the cross-host hop, and GitHub's
|
|
# redirect target carries its own short-lived token in the URL —
|
|
# so following is both necessary there and harmless on Gitea.
|
|
"follow_redirects": True,
|
|
}
|
|
if self._transport is not None:
|
|
kwargs["transport"] = self._transport
|
|
return httpx.AsyncClient(**kwargs)
|
|
|
|
async def _get(self, client: httpx.AsyncClient, url: str, **kw) -> httpx.Response:
|
|
try:
|
|
resp = await client.get(url, **kw)
|
|
except httpx.HTTPError as exc:
|
|
# str(exc) on transport errors names hosts and timeouts, never
|
|
# headers — safe, and the detail is what makes the test button useful.
|
|
raise ForgeError(f"forge unreachable: {exc}") from exc
|
|
if resp.status_code == 404:
|
|
raise ForgeNotFound(f"not found on forge: {url}")
|
|
if resp.status_code in (401, 403):
|
|
raise ForgeError("forge rejected the token (check its read scope)")
|
|
if resp.status_code >= 400:
|
|
raise ForgeError(f"forge returned HTTP {resp.status_code} for {url}")
|
|
return resp
|
|
|
|
def _decode_contents(self, payload, path: str) -> str:
|
|
"""Both forges speak the same contents-API dialect: a base64 file
|
|
object, a list for a directory."""
|
|
if isinstance(payload, list):
|
|
raise ForgeNotFound(f"{path} is a directory on the forge, not a file")
|
|
if payload.get("type") != "file":
|
|
raise ForgeNotFound(
|
|
f"{path} is a {payload.get('type', 'non-file')} on the forge"
|
|
)
|
|
if payload.get("encoding") != "base64" or payload.get("content") is None:
|
|
raise ForgeError(f"forge returned no readable content for {path}")
|
|
try:
|
|
return base64.b64decode(payload["content"]).decode("utf-8")
|
|
except (binascii.Error, UnicodeDecodeError) as exc:
|
|
raise ForgeError(f"forge content for {path} is not utf-8 text") from exc
|
|
|
|
async def _newest_commit(
|
|
self, client: httpx.AsyncClient, repo: str, path: str, ref: str
|
|
) -> str:
|
|
params: dict = {**self._commit_page_params, "path": path}
|
|
if ref:
|
|
params["sha"] = ref
|
|
resp = await self._get(client, f"/repos/{repo}/commits", params=params)
|
|
payload = resp.json()
|
|
# Tolerant parse on purpose: the caller uses this as an optimization
|
|
# and falls back to read_file, so a surprising payload must read as
|
|
# "don't know", never break a pull.
|
|
if isinstance(payload, list) and payload and isinstance(payload[0], dict):
|
|
return str(payload[0].get("sha") or "")
|
|
return ""
|
|
|
|
async def latest_commit(self, repo: str, path: str, ref: str = "") -> str:
|
|
"""The newest commit touching ``path`` — "" when it can't be told.
|
|
|
|
The cached-SHA short-circuit (#2693): when a snippet's provenance
|
|
already names a commit, this one small call can prove the file
|
|
hasn't moved since — no content transfer, which is what keeps
|
|
pull-time freshness inside GitHub's rate limits.
|
|
"""
|
|
async with self._client() as client:
|
|
return await self._newest_commit(client, repo, path, ref)
|
|
|
|
def _archive_url(self, repo: str, ref: str) -> str:
|
|
raise NotImplementedError
|
|
|
|
async def archive(self, repo: str, ref: str) -> bytes:
|
|
"""The repo's content at ``ref`` as a gzipped tarball, in one request.
|
|
|
|
Coverage measurement (step 7) needs every source file's text; per-file
|
|
reads would mean one API call per file, so the archive endpoint is the
|
|
only shape that scales past toy repos. Callers must never run this in
|
|
a request path — it moves the whole repo.
|
|
"""
|
|
async with self._client() as client:
|
|
resp = await self._get(
|
|
client, self._archive_url(repo, ref), timeout=_ARCHIVE_TIMEOUT
|
|
)
|
|
return resp.content
|
|
|
|
async def default_branch(self, repo: str) -> str:
|
|
async with self._client() as client:
|
|
resp = await self._get(client, f"/repos/{repo}")
|
|
branch = (resp.json() or {}).get("default_branch") or ""
|
|
if not branch:
|
|
raise ForgeError(f"forge reported no default branch for {repo}")
|
|
return branch
|
|
|
|
|
|
class GiteaForge(ForgeAdapter):
|
|
"""The Gitea implementation of the forge contract, over its REST API."""
|
|
|
|
kind = "gitea"
|
|
# stat/verification/files add per-commit work Gitea skips when told to.
|
|
_commit_page_params = {"limit": 1, "stat": "false"}
|
|
|
|
def _api_base(self) -> str:
|
|
return f"{self.base_url}/api/v1"
|
|
|
|
def _headers(self) -> dict:
|
|
return {"Authorization": f"token {self._token}"}
|
|
|
|
async def read_file(self, repo: str, path: str, ref: str = "") -> ForgeFile:
|
|
"""Read one file's current content, with the commit it was served at.
|
|
|
|
`repo` is the API path from resolve_repo ("owner/repo"); `ref` is a
|
|
branch, tag, or commit — empty means the default branch.
|
|
"""
|
|
params = {"ref": ref} if ref else None
|
|
async with self._client() as client:
|
|
resp = await self._get(
|
|
client,
|
|
f"/repos/{repo}/contents/{quote(path, safe='/')}",
|
|
params=params,
|
|
)
|
|
payload = resp.json()
|
|
content = self._decode_contents(payload, path)
|
|
return ForgeFile(
|
|
content=content,
|
|
# last_commit_sha is the commit that last touched the file — the
|
|
# honest provenance stamp. The blob sha is a content address, not
|
|
# a point in history, so it is deliberately not surfaced.
|
|
commit_sha=payload.get("last_commit_sha") or "",
|
|
path=payload.get("path") or path,
|
|
)
|
|
|
|
def _archive_url(self, repo: str, ref: str) -> str:
|
|
return f"/repos/{repo}/archive/{quote(ref, safe='')}.tar.gz"
|
|
|
|
async def check(self) -> dict:
|
|
"""Health probe for the settings test button: reach the forge AND
|
|
prove the token is accepted. Returns {"ok", "version", "username"}."""
|
|
async with self._client() as client:
|
|
version = (await self._get(client, "/version")).json() or {}
|
|
user = (await self._get(client, "/user")).json() or {}
|
|
return {
|
|
"ok": True,
|
|
"version": version.get("version") or "",
|
|
"username": user.get("login") or user.get("username") or "",
|
|
}
|
|
|
|
|
|
class GitHubForge(ForgeAdapter):
|
|
"""The GitHub implementation — the second one, which is the point (#2693):
|
|
it proves the seam is a contract rather than a Gitea-shaped hole. Works
|
|
against github.com and GitHub Enterprise; the token is a fine-grained PAT
|
|
with Contents: Read-only (or a classic token with `repo` read)."""
|
|
|
|
kind = "github"
|
|
_commit_page_params = {"per_page": 1}
|
|
|
|
_API_VERSION = "2022-11-28"
|
|
|
|
def _api_base(self) -> str:
|
|
# github.com's API lives on its own host; GitHub Enterprise serves
|
|
# the same API under the instance at /api/v3.
|
|
if self.host == "github.com":
|
|
return "https://api.github.com"
|
|
return f"{self.base_url}/api/v3"
|
|
|
|
def _headers(self) -> dict:
|
|
return {
|
|
"Authorization": f"Bearer {self._token}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": self._API_VERSION,
|
|
}
|
|
|
|
async def read_file(self, repo: str, path: str, ref: str = "") -> ForgeFile:
|
|
"""Same contents-API dialect as Gitea, minus one field: GitHub's
|
|
payload carries only the blob sha — a content address, not a point in
|
|
history — so the provenance stamp costs one extra commits call. ""
|
|
when even that can't be told; consumers already treat an empty stamp
|
|
as "don't restamp"."""
|
|
params = {"ref": ref} if ref else None
|
|
async with self._client() as client:
|
|
resp = await self._get(
|
|
client,
|
|
f"/repos/{repo}/contents/{quote(path, safe='/')}",
|
|
params=params,
|
|
)
|
|
payload = resp.json()
|
|
content = self._decode_contents(payload, path)
|
|
try:
|
|
commit_sha = await self._newest_commit(client, repo, path, ref)
|
|
except ForgeError:
|
|
commit_sha = ""
|
|
return ForgeFile(
|
|
content=content,
|
|
commit_sha=commit_sha,
|
|
path=payload.get("path") or path,
|
|
)
|
|
|
|
def _archive_url(self, repo: str, ref: str) -> str:
|
|
return f"/repos/{repo}/tarball/{quote(ref, safe='')}"
|
|
|
|
async def check(self) -> dict:
|
|
"""GitHub has no /version endpoint; proving the token against /user
|
|
is the whole probe, and the pinned API version stands in as the
|
|
version string."""
|
|
async with self._client() as client:
|
|
user = (await self._get(client, "/user")).json() or {}
|
|
return {
|
|
"ok": True,
|
|
"version": f"GitHub API {self._API_VERSION}",
|
|
"username": user.get("login") or "",
|
|
}
|
|
|
|
|
|
_FORGE_CLASSES: dict[str, type[ForgeAdapter]] = {
|
|
"gitea": GiteaForge,
|
|
"github": GitHubForge,
|
|
}
|
|
|
|
|
|
def build_adapter(
|
|
kind: str, base_url: str, token: str, *, transport=None
|
|
) -> ForgeAdapter | None:
|
|
"""One validated adapter from raw connection values, or None.
|
|
|
|
None means "this connection cannot serve reads" — the same contract the
|
|
old instance-wide lookup had, applied per keyring row. Misconfigurations
|
|
are logged, never raised: a bad row must not break the reads the good
|
|
rows can still serve.
|
|
"""
|
|
kind = (kind or "").strip().lower()
|
|
base_url = (base_url or "").rstrip("/")
|
|
cls = _FORGE_CLASSES.get(kind)
|
|
if cls is None:
|
|
if kind:
|
|
# A kind we don't implement is a misconfiguration, not "off" —
|
|
# say so once per lookup rather than silently reading as absent.
|
|
logger.warning("unknown forge kind %r configured — connection disabled", kind)
|
|
return None
|
|
if not base_url or not token:
|
|
return None
|
|
if not base_url.startswith(("http://", "https://")):
|
|
logger.warning("forge base URL %r has no http(s) scheme — connection disabled", base_url)
|
|
return None
|
|
return cls(base_url, token, transport=transport)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ForgeSelector:
|
|
"""The forge reads available to one project owner (#2778).
|
|
|
|
Consumers ask it to serve a REPO, not to hand over "the forge": resolve()
|
|
walks the owner's adapters and returns the (adapter, api_repo) pair for
|
|
the first one whose host serves the repo — or None, which every consumer
|
|
treats exactly as the old "no forge configured" state. An empty selector
|
|
IS rule #115's baseline.
|
|
"""
|
|
|
|
adapters: tuple[ForgeAdapter, ...] = ()
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
return bool(self.adapters)
|
|
|
|
def resolve(self, repo_or_url: str) -> tuple[ForgeAdapter, str] | None:
|
|
for adapter in self.adapters:
|
|
repo = adapter.resolve_repo(repo_or_url)
|
|
if repo is not None:
|
|
return adapter, repo
|
|
return None
|
|
|
|
|
|
async def get_forges(
|
|
owner_id: int, project_id: int | None = None, *, transport=None
|
|
) -> ForgeSelector:
|
|
"""The forge selector for reads on behalf of ``owner_id``'s records.
|
|
|
|
The keyring model (#2778): every server-side forge read for a record runs
|
|
on the PROJECT OWNER's connections, resolved by repo host — a forge token
|
|
is a user's credential, and one user's reads must never ride another
|
|
user's token. Pass the record's ``project_id`` so the per-project pin
|
|
applies: a pinned project uses ONLY its pinned connection (explicit and
|
|
auditable); a pin that no longer belongs to the owner (ownership moved) is
|
|
ignored with a warning rather than honored across users.
|
|
|
|
The env config (FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) survives as an
|
|
implicit keyring entry for ADMIN owners only — it is the operator's
|
|
token, so it must not serve other users' reads — and a stored row for the
|
|
same host beats it, because the UI writes rows.
|
|
"""
|
|
from scribe.models import async_session
|
|
from scribe.models.forge_connection import ForgeConnection
|
|
from scribe.models.project import Project
|
|
from scribe.models.user import User
|
|
|
|
async with async_session() as session:
|
|
pinned_id = None
|
|
if project_id:
|
|
pinned_id = (
|
|
await session.execute(
|
|
select(Project.forge_connection_id).where(Project.id == project_id)
|
|
)
|
|
).scalar_one_or_none()
|
|
rows = list(
|
|
(
|
|
await session.execute(
|
|
select(ForgeConnection)
|
|
.where(ForgeConnection.user_id == owner_id)
|
|
.order_by(ForgeConnection.id)
|
|
)
|
|
).scalars().all()
|
|
)
|
|
role = ""
|
|
if Config.FORGE_KIND and Config.FORGE_BASE_URL and Config.FORGE_TOKEN:
|
|
role = (
|
|
await session.execute(select(User.role).where(User.id == owner_id))
|
|
).scalar_one_or_none() or ""
|
|
|
|
if pinned_id:
|
|
pin = next((r for r in rows if r.id == pinned_id), None)
|
|
if pin is not None:
|
|
adapter = build_adapter(pin.kind, pin.base_url, pin.token, transport=transport)
|
|
return ForgeSelector((adapter,) if adapter is not None else ())
|
|
logger.warning(
|
|
"project %s pins forge connection %s the owner (%s) does not hold — pin ignored",
|
|
project_id, pinned_id, owner_id,
|
|
)
|
|
|
|
adapters: list[ForgeAdapter] = []
|
|
for row in rows:
|
|
adapter = build_adapter(row.kind, row.base_url, row.token, transport=transport)
|
|
if adapter is not None:
|
|
adapters.append(adapter)
|
|
if role == "admin":
|
|
env = build_adapter(
|
|
Config.FORGE_KIND, Config.FORGE_BASE_URL, Config.FORGE_TOKEN,
|
|
transport=transport,
|
|
)
|
|
if env is not None and all(a.host != env.host for a in adapters):
|
|
adapters.append(env)
|
|
return ForgeSelector(tuple(adapters))
|