feat(forge): adapter seam + Gitea implementation — optional read access to the operator's forge (#2689)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 36s

Step 4 of milestone 288 (decision #2686). services/forge.py defines the
contract steps 5-7 consume — read_file (content + last_commit_sha, the
provenance stamp), default_branch, resolve_repo, check — with GiteaForge
as the first implementation over the REST contents/repo/version/user
endpoints. Repo identity reuses normalize_repo_key: the host segment
selects whether this forge serves a recorded repo, the remainder is the
API path, so no new identity scheme exists. Read-only by construction;
errors never carry the token; first outbound-HTTP timeout convention
(5s total, no retries — the consumer's fallback is the retry policy).

OPTIONAL per instance (rule #115): get_forge() returns None when
unconfigured and every consumer treats None as today's behavior. Config
lives in admin settings (Settings → Config → Git Forge: kind/base
URL/token, save + test-connection probe reporting version + identity),
with FORGE_* env / Docker-secret fallbacks; DB wins so a UI edit can't
silently lose to an env var. Token treatment follows the smtp_password
convention (masked on read, mask-sentinel skipped on write, absent from
audit details) — and wiring it surfaced that the generic GET/PUT
/api/settings dump bypassed that masking for the owning admin's raw KV
rows, so secret keys are now masked there too (fixes the same exposure
for smtp_password).

Contract tests run against httpx.MockTransport as the fake forge — the
reference behaviors the GitHub adapter (step 8) must reproduce — plus
the off-by-default gate, partial-config-is-off, env-vs-DB precedence,
and route/mask structural checks. Also: the step-2 definition detector
learned to skip dunders after flagging __init__ as 'already defined in
4 files' on this step's own build — guaranteed noise for a hint that
must stay trustworthy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 12:37:27 -04:00
co-authored by Claude Fable 5
parent 7d26a3fc6a
commit 13e428c596
7 changed files with 696 additions and 3 deletions
+105
View File
@@ -420,6 +420,16 @@ const baseUrl = ref("");
const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false);
// Git forge integration (admin only, #2689). The token round-trips masked;
// the server treats the mask as "unchanged".
const forge = ref({ kind: "", base_url: "", token: "" });
const forgeKinds = ref<string[]>(["gitea"]);
const forgeConfigured = ref(false);
const savingForge = ref(false);
const forgeSaved = ref(false);
const testingForge = ref(false);
const forgeTestResult = ref<{ ok: boolean; message: string } | null>(null);
// Search test (SearXNG)
const searxngConfigured = ref(false);
@@ -565,10 +575,25 @@ onMounted(async () => {
} catch {
// base URL not configured yet
}
try {
await loadForgeSettings();
} catch {
// forge not configured yet
}
}
_loadTabContent(activeTab.value);
});
async function loadForgeSettings() {
const cfg = await apiGet<{
kind: string; base_url: string; token: string;
configured: boolean; kinds: string[];
}>("/api/admin/forge");
forge.value = { kind: cfg.kind, base_url: cfg.base_url, token: cfg.token };
forgeConfigured.value = cfg.configured;
if (cfg.kinds?.length) forgeKinds.value = cfg.kinds;
}
async function changeEmail() {
changingEmail.value = true;
try {
@@ -734,6 +759,45 @@ async function sendTestEmail() {
}
}
async function saveForge() {
savingForge.value = true;
forgeSaved.value = false;
forgeTestResult.value = null;
try {
await apiPut("/api/admin/forge", forge.value);
await loadForgeSettings();
forgeSaved.value = true;
setTimeout(() => (forgeSaved.value = false), 2000);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save forge settings", "error");
} finally {
savingForge.value = false;
}
}
async function testForge() {
testingForge.value = true;
forgeTestResult.value = null;
try {
const res = await apiPost<{ version: string; username: string }>(
"/api/admin/forge/test", {},
);
forgeTestResult.value = {
ok: true,
message: `Connected — Gitea ${res.version}, authenticated as ${res.username}`,
};
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
forgeTestResult.value = {
ok: false,
message: body?.error || "Connection test failed",
};
} finally {
testingForge.value = false;
}
}
async function saveBaseUrl() {
savingBaseUrl.value = true;
baseUrlSaved.value = false;
@@ -2090,6 +2154,47 @@ function formatUserDate(iso: string): string {
</div>
</section>
<section class="settings-section full-width">
<h2>Git Forge</h2>
<p class="section-desc">
Optional read-only connection to your git forge (Gitea) so snippet
code can be fetched and drift-checked server-side. A read-scope API
token is enough. Leave the kind unset to keep the integration off.
</p>
<div class="smtp-grid">
<div class="field">
<label for="forge-kind">Forge</label>
<select id="forge-kind" v-model="forge.kind" class="input">
<option value="">Off</option>
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="field">
<label for="forge-base-url">Base URL</label>
<input id="forge-base-url" v-model="forge.base_url" type="text" placeholder="https://git.example.com" class="input" />
</div>
<div class="field">
<label for="forge-token">API Token (read scope)</label>
<input id="forge-token" v-model="forge.token" type="password" class="input" />
</div>
</div>
<div class="actions" style="margin-bottom: 1.25rem;">
<button class="btn-primary" @click="saveForge" :disabled="savingForge">
{{ savingForge ? "Saving..." : "Save Forge Settings" }}
</button>
<button class="btn-ghost" @click="testForge" :disabled="testingForge || !forgeConfigured">
{{ testingForge ? "Testing..." : "Test Connection" }}
</button>
<span v-if="forgeSaved" class="saved-msg">Saved!</span>
</div>
<p
v-if="forgeTestResult"
:class="forgeTestResult.ok ? 'text-success' : 'text-error'"
>
{{ forgeTestResult.message }}
</p>
</section>
</div>
<!-- Users -->
+4 -1
View File
@@ -111,10 +111,13 @@ if [ -n "$repo_root" ] && [ -n "$code" ]; then
if (t != "") print "sym\t" t; next
}
# Keyword-announced definitions, functions and named types alike.
# Dunders are skipped: every class defines __init__, so "already defined
# in N other files" is guaranteed noise for them — and noise is what
# teaches sessions to skip the hint.
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t)
if (t != "") print "sym\t" t; next
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
}
# Arrow/expression assignment: const name = (…) / let name = async (
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
+9
View File
@@ -60,6 +60,15 @@ class Config:
# the MCP layer doesn't proxy web search (Claude has its own).
SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "")
# Git forge integration (#2689) — optional read access to the operator's
# forge so snippet bodies can be fetched/verified server-side. Normally
# configured in Settings → Config (stored as admin settings); these env
# fallbacks exist so a deployment can keep the token in a Docker secret
# instead of the database. DB value wins when both are set.
FORGE_KIND: str = os.environ.get("FORGE_KIND", "")
FORGE_BASE_URL: str = os.environ.get("FORGE_BASE_URL", "").rstrip("/")
FORGE_TOKEN: str = _read_secret("FORGE_TOKEN", "FORGE_TOKEN_FILE", "")
@classmethod
def oidc_enabled(cls) -> bool:
return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
+78
View File
@@ -19,6 +19,15 @@ from scribe.services.backup import (
restore_full_backup,
)
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from scribe.services.forge import (
FORGE_BASE_URL_KEY,
FORGE_KIND_KEY,
FORGE_KINDS,
FORGE_TOKEN_KEY,
ForgeError,
forge_config,
get_forge,
)
from scribe.services.logging import get_logs, get_log_stats, log_audit
from scribe.services.notifications import send_invitation_email
from scribe.services.settings import (
@@ -157,6 +166,75 @@ async def test_smtp():
return jsonify({"error": str(e)}), 500
_TOKEN_MASK = "********"
@admin_bp.route("/forge", methods=["GET"])
@admin_required
async def get_forge_settings():
cfg = await forge_config()
return jsonify({
"kind": cfg["kind"],
"base_url": cfg["base_url"],
# The token itself never leaves the server — the smtp_password
# convention: masked when set, empty when not.
"token": _TOKEN_MASK if cfg["token"] else "",
"configured": bool(await get_forge()),
"kinds": list(FORGE_KINDS),
})
@admin_bp.route("/forge", methods=["PUT"])
@admin_required
async def update_forge_settings():
data = await request.get_json() or {}
uid = get_current_user_id()
kind = str(data.get("kind", "")).strip().lower()
if kind and kind not in FORGE_KINDS:
return jsonify({"error": f"Unknown forge kind {kind!r}"}), 400
base_url = str(data.get("base_url", "")).strip().rstrip("/")
if base_url and not base_url.startswith(("http://", "https://")):
return jsonify({"error": "Forge base URL must use http or https"}), 400
await set_admin_setting(FORGE_KIND_KEY, kind)
await set_admin_setting(FORGE_BASE_URL_KEY, base_url)
token = data.get("token")
# The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration.
if token is not None and token != _TOKEN_MASK:
await set_admin_setting(FORGE_TOKEN_KEY, str(token))
# The token is deliberately absent from the audit detail.
await log_audit(
"forge_config", user_id=uid, username=g.user.username,
ip_address=request.remote_addr,
details={"kind": kind, "base_url": base_url},
)
return jsonify({"status": "ok"})
@admin_bp.route("/forge/test", methods=["POST"])
@admin_required
async def test_forge():
"""Probe the SAVED forge config: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied to integrations)."""
uid = get_current_user_id()
forge = await get_forge()
if forge is None:
return jsonify({"error": "Forge is not configured — save kind, base URL and token first"}), 400
try:
result = await forge.check()
except ForgeError as e:
return jsonify({"error": str(e)}), 502
await log_audit(
"forge_test", user_id=uid, username=g.user.username,
ip_address=request.remote_addr,
details={"ok": True, "username": result.get("username", "")},
)
return jsonify(result)
@admin_bp.route("/logs", methods=["GET"])
@admin_required
async def list_logs():
+20 -2
View File
@@ -16,13 +16,27 @@ logger = logging.getLogger(__name__)
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# Keys whose values are credentials. The admin endpoints that own them mask on
# read and skip the mask on write; this generic KV surface has to apply the
# same treatment, or it silently un-masks what those endpoints masked — the
# rows live on the admin's own user_id, so the plain GET returned them raw.
_SECRET_KEYS = frozenset({"smtp_password", "forge_token"})
_SECRET_MASK = "********"
def _masked(settings: dict) -> dict:
return {
k: (_SECRET_MASK if k in _SECRET_KEYS and v else v)
for k, v in settings.items()
}
@settings_bp.route("", methods=["GET"])
@login_required
async def get_settings_route():
uid = get_current_user_id()
settings = await get_all_settings(uid)
return jsonify(settings)
return jsonify(_masked(settings))
@settings_bp.route("", methods=["PUT"])
@@ -36,6 +50,10 @@ async def update_settings_route():
to_save = {}
for k, v in data.items():
str_v = str(v)
# A masked secret round-tripping through a client is "unchanged", not
# a request to store the mask over the real credential.
if k in _SECRET_KEYS and str_v == _SECRET_MASK:
continue
if not str_v:
await delete_setting(uid, k)
else:
@@ -45,7 +63,7 @@ async def update_settings_route():
await set_settings_batch(uid, to_save)
settings = await get_all_settings(uid)
return jsonify(settings)
return jsonify(_masked(settings))
@settings_bp.route("/search", methods=["GET"])
+237
View File
@@ -0,0 +1,237 @@
"""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 instance (rule #115). `get_forge()` returns None when nothing
is configured, and every consumer must treat None as "keep today's
behavior". An install that never configures a forge is not degraded — it
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 /
default_branch / resolve_repo / check. GitHub later implements this same
contract (step 8); 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 scribe.config import Config
from scribe.services.repo_bindings import normalize_repo_key
from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__)
FORGE_KIND_KEY = "forge_kind"
FORGE_BASE_URL_KEY = "forge_base_url"
FORGE_TOKEN_KEY = "forge_token"
# Kinds an instance can configure. GitHub joins in step 8 of milestone 288.
FORGE_KINDS = ("gitea",)
# 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)
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:
return (urlsplit(url).hostname or "").lower()
class GiteaForge:
"""The Gitea implementation of the forge contract, over its REST API.
`transport` exists for tests: httpx.MockTransport makes the contract
testable without a live server or a new dependency. Production callers
never pass it.
"""
kind = "gitea"
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 _client(self) -> httpx.AsyncClient:
kwargs: dict = {
"base_url": f"{self.base_url}/api/v1",
"headers": {"Authorization": f"token {self._token}"},
"timeout": _TIMEOUT,
}
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
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()
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:
content = 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
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,
)
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
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 "",
}
async def forge_config() -> dict:
"""The instance's forge configuration, DB-first with env fallback.
The env channel exists so a deployment can keep the token out of the
database entirely (Docker secret via FORGE_TOKEN_FILE) — the DB value wins
when both are present because the admin UI writes there, and a UI edit
that silently loses to an env var would look exactly like a broken form.
"""
return {
"kind": (await get_admin_setting(FORGE_KIND_KEY, "") or Config.FORGE_KIND)
.strip()
.lower(),
"base_url": (
await get_admin_setting(FORGE_BASE_URL_KEY, "") or Config.FORGE_BASE_URL
).rstrip("/"),
"token": await get_admin_setting(FORGE_TOKEN_KEY, "") or Config.FORGE_TOKEN,
}
async def get_forge(*, transport=None) -> GiteaForge | None:
"""The configured forge adapter, or None — and None means "behave exactly
as if this module did not exist", which every consumer must honor."""
cfg = await forge_config()
if cfg["kind"] not in FORGE_KINDS:
if cfg["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 — forge disabled", cfg["kind"])
return None
if not cfg["base_url"] or not cfg["token"]:
return None
if not cfg["base_url"].startswith(("http://", "https://")):
logger.warning("forge base URL %r has no http(s) scheme — forge disabled", cfg["base_url"])
return None
return GiteaForge(cfg["base_url"], cfg["token"], transport=transport)
+243
View File
@@ -0,0 +1,243 @@
"""Forge adapter contract tests (#2689) — the Gitea implementation against a
mocked transport, plus the configuration gate.
httpx.MockTransport is the fake forge: the adapter takes an injectable
transport precisely so the CONTRACT (URLs hit, auth header shape, payload
decoding, error taxonomy) is testable with no live server and no new
dependency. These are the reference behaviors step 8's GitHub adapter must
reproduce.
The most load-bearing tests are the OFF ones: an unconfigured instance must
get None from get_forge(), because every consumer treats None as "behave as if
the module didn't exist" (rule #115 — the baseline install has no forge).
"""
import base64
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from scribe.services.forge import (
ForgeError,
ForgeNotFound,
GiteaForge,
get_forge,
)
BASE = "https://git.example.com"
def _forge(handler) -> GiteaForge:
return GiteaForge(BASE, "tok-123", transport=httpx.MockTransport(handler))
def _json(status: int, payload) -> httpx.Response:
return httpx.Response(status, json=payload)
# --- resolve_repo: the join between recorded repos and this forge ------------
@pytest.mark.parametrize(
("recorded", "expected"),
[
("https://git.example.com/alice/Widget.git", "alice/widget"),
("git@git.example.com:alice/widget.git", "alice/widget"),
("git.example.com/alice/widget", "alice/widget"),
# Nested (GitLab-style) groups survive as the API path remainder.
("https://git.example.com/team/sub/widget", "team/sub/widget"),
# Another host is a NORMAL miss, not an error.
("https://github.com/alice/widget", None),
("", None),
("not a url", None),
# Host alone, no owner/repo remainder.
("git.example.com", None),
],
)
def test_resolve_repo_matches_by_host_and_yields_the_api_path(recorded, expected):
forge = GiteaForge(BASE, "tok")
assert forge.resolve_repo(recorded) == expected
# --- read_file ---------------------------------------------------------------
async def test_read_file_decodes_content_and_carries_the_commit():
seen = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["auth"] = request.headers.get("Authorization")
return _json(200, {
"type": "file",
"encoding": "base64",
"content": base64.b64encode("def x():\n return 1\n".encode()).decode(),
"sha": "blob" * 10,
"last_commit_sha": "c" * 40,
"path": "src/x.py",
})
got = await _forge(handler).read_file("alice/widget", "src/x.py", ref="dev")
assert got.content == "def x():\n return 1\n"
assert got.commit_sha == "c" * 40
assert got.path == "src/x.py"
assert "/api/v1/repos/alice/widget/contents/src/x.py" in seen["url"]
assert "ref=dev" in seen["url"]
assert seen["auth"] == "token tok-123"
async def test_read_file_404_is_not_found_and_a_directory_is_too():
with pytest.raises(ForgeNotFound):
await _forge(lambda r: _json(404, {"message": "no"})).read_file(
"alice/widget", "gone.py"
)
# The contents API returns a LIST for a directory — that's "no such file",
# not a decoding error.
with pytest.raises(ForgeNotFound):
await _forge(lambda r: _json(200, [{"type": "file"}])).read_file(
"alice/widget", "src"
)
async def test_read_file_auth_failure_names_the_scope_never_the_token():
with pytest.raises(ForgeError) as err:
await _forge(lambda r: _json(401, {})).read_file("alice/widget", "x.py")
assert "tok-123" not in str(err.value)
assert "scope" in str(err.value)
async def test_read_file_binary_content_is_a_forge_error():
def handler(request):
return _json(200, {
"type": "file", "encoding": "base64",
"content": base64.b64encode(b"\xff\xfe\x00\x01").decode(),
})
with pytest.raises(ForgeError):
await _forge(handler).read_file("alice/widget", "img.bin")
async def test_unreachable_forge_is_a_forge_error_not_a_crash():
def handler(request):
raise httpx.ConnectError("boom", request=request)
with pytest.raises(ForgeError):
await _forge(handler).read_file("alice/widget", "x.py")
# --- default_branch / check --------------------------------------------------
async def test_default_branch_reads_the_repo_record():
forge = _forge(lambda r: _json(200, {"default_branch": "dev"}))
assert await forge.default_branch("alice/widget") == "dev"
async def test_check_reports_version_and_identity():
def handler(request):
if request.url.path.endswith("/version"):
return _json(200, {"version": "1.23.1"})
return _json(200, {"login": "scribe-bot"})
result = await _forge(handler).check()
assert result == {"ok": True, "version": "1.23.1", "username": "scribe-bot"}
# --- the configuration gate --------------------------------------------------
def _settings(values: dict):
async def fake(key, default=""):
return values.get(key, default)
return patch("scribe.services.forge.get_admin_setting", AsyncMock(side_effect=fake))
async def test_unconfigured_instance_gets_none():
with _settings({}), patch("scribe.services.forge.Config") as cfg:
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
assert await get_forge() is None
async def test_partial_config_is_still_off():
# A base URL with no token (or vice versa) must not half-enable anything.
for values in (
{"forge_kind": "gitea", "forge_base_url": BASE},
{"forge_kind": "gitea", "forge_token": "tok"},
{"forge_base_url": BASE, "forge_token": "tok"}, # no kind selected
):
with _settings(values), patch("scribe.services.forge.Config") as cfg:
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
assert await get_forge() is None
async def test_unknown_kind_disables_with_a_warning_not_a_crash():
with _settings({
"forge_kind": "sourcehut", "forge_base_url": BASE, "forge_token": "tok",
}), patch("scribe.services.forge.Config") as cfg:
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
assert await get_forge() is None
async def test_full_config_builds_a_gitea_adapter():
with _settings({
"forge_kind": "gitea",
"forge_base_url": BASE + "/", # trailing slash normalized away
"forge_token": "tok",
}), patch("scribe.services.forge.Config") as cfg:
cfg.FORGE_KIND = cfg.FORGE_BASE_URL = cfg.FORGE_TOKEN = ""
forge = await get_forge()
assert isinstance(forge, GiteaForge)
assert forge.base_url == BASE
assert forge.host == "git.example.com"
async def test_env_channel_fills_gaps_but_db_wins():
# Docker-secret deployments set FORGE_* env; an admin-UI value overrides.
with _settings({"forge_base_url": "https://db.example.com"}), \
patch("scribe.services.forge.Config") as cfg:
cfg.FORGE_KIND = "gitea"
cfg.FORGE_BASE_URL = "https://env.example.com"
cfg.FORGE_TOKEN = "env-tok"
forge = await get_forge()
assert isinstance(forge, GiteaForge)
assert forge.host == "db.example.com"
def test_forge_error_taxonomy_is_catchable_as_one_family():
assert issubclass(ForgeNotFound, ForgeError)
assert issubclass(ForgeError, RuntimeError)
def test_adapter_contract_surface():
"""Step 8's GitHub adapter implements exactly this surface — pin it."""
for method in ("read_file", "default_branch", "resolve_repo", "check"):
assert callable(getattr(GiteaForge, method))
assert GiteaForge.kind == "gitea"
def test_admin_routes_registered():
from scribe.app import create_app
from scribe.routes import admin as admin_routes
for name in ("get_forge_settings", "update_forge_settings", "test_forge"):
assert callable(getattr(admin_routes, name))
rules = {r.rule for r in create_app().url_map.iter_rules()}
assert "/api/admin/forge" in rules
assert "/api/admin/forge/test" in rules
def test_settings_kv_surface_masks_the_forge_token():
"""The generic /api/settings dump masked nothing — the admin endpoints'
masking was bypassable by reading the raw KV rows (found while wiring the
forge token; smtp_password had the same exposure)."""
from scribe.routes.settings import _SECRET_KEYS, _masked
out = _masked({"forge_token": "tok-123", "smtp_password": "pw", "theme": "dark"})
assert out["forge_token"] == "********"
assert out["smtp_password"] == "********"
assert out["theme"] == "dark"
assert {"forge_token", "smtp_password"} <= set(_SECRET_KEYS)
# An unset secret stays empty rather than reading as a set-but-masked one.
assert _masked({"forge_token": ""})["forge_token"] == ""
def test_config_has_the_docker_secret_channel():
from scribe.config import Config
for attr in ("FORGE_KIND", "FORGE_BASE_URL", "FORGE_TOKEN"):
assert hasattr(Config, attr)