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
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:
@@ -420,6 +420,16 @@ const baseUrl = ref("");
|
|||||||
const savingBaseUrl = ref(false);
|
const savingBaseUrl = ref(false);
|
||||||
const baseUrlSaved = 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)
|
// Search test (SearXNG)
|
||||||
const searxngConfigured = ref(false);
|
const searxngConfigured = ref(false);
|
||||||
@@ -565,10 +575,25 @@ onMounted(async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// base URL not configured yet
|
// base URL not configured yet
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await loadForgeSettings();
|
||||||
|
} catch {
|
||||||
|
// forge not configured yet
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_loadTabContent(activeTab.value);
|
_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() {
|
async function changeEmail() {
|
||||||
changingEmail.value = true;
|
changingEmail.value = true;
|
||||||
try {
|
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() {
|
async function saveBaseUrl() {
|
||||||
savingBaseUrl.value = true;
|
savingBaseUrl.value = true;
|
||||||
baseUrlSaved.value = false;
|
baseUrlSaved.value = false;
|
||||||
@@ -2090,6 +2154,47 @@ function formatUserDate(iso: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ── Users ── -->
|
<!-- ── Users ── -->
|
||||||
|
|||||||
@@ -111,10 +111,13 @@ if [ -n "$repo_root" ] && [ -n "$code" ]; then
|
|||||||
if (t != "") print "sym\t" t; next
|
if (t != "") print "sym\t" t; next
|
||||||
}
|
}
|
||||||
# Keyword-announced definitions, functions and named types alike.
|
# 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_$]/)) {
|
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)
|
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
|
||||||
sub(/[^A-Za-z0-9_$].*$/, "", 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 (
|
# 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:]]*)?[(<]/)) {
|
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ class Config:
|
|||||||
# the MCP layer doesn't proxy web search (Claude has its own).
|
# the MCP layer doesn't proxy web search (Claude has its own).
|
||||||
SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "")
|
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
|
@classmethod
|
||||||
def oidc_enabled(cls) -> bool:
|
def oidc_enabled(cls) -> bool:
|
||||||
return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
|
return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ from scribe.services.backup import (
|
|||||||
restore_full_backup,
|
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.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.logging import get_logs, get_log_stats, log_audit
|
||||||
from scribe.services.notifications import send_invitation_email
|
from scribe.services.notifications import send_invitation_email
|
||||||
from scribe.services.settings import (
|
from scribe.services.settings import (
|
||||||
@@ -157,6 +166,75 @@ async def test_smtp():
|
|||||||
return jsonify({"error": str(e)}), 500
|
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_bp.route("/logs", methods=["GET"])
|
||||||
@admin_required
|
@admin_required
|
||||||
async def list_logs():
|
async def list_logs():
|
||||||
|
|||||||
@@ -16,13 +16,27 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
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"])
|
@settings_bp.route("", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
async def get_settings_route():
|
async def get_settings_route():
|
||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
settings = await get_all_settings(uid)
|
settings = await get_all_settings(uid)
|
||||||
return jsonify(settings)
|
return jsonify(_masked(settings))
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route("", methods=["PUT"])
|
@settings_bp.route("", methods=["PUT"])
|
||||||
@@ -36,6 +50,10 @@ async def update_settings_route():
|
|||||||
to_save = {}
|
to_save = {}
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
str_v = str(v)
|
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:
|
if not str_v:
|
||||||
await delete_setting(uid, k)
|
await delete_setting(uid, k)
|
||||||
else:
|
else:
|
||||||
@@ -45,7 +63,7 @@ async def update_settings_route():
|
|||||||
await set_settings_batch(uid, to_save)
|
await set_settings_batch(uid, to_save)
|
||||||
|
|
||||||
settings = await get_all_settings(uid)
|
settings = await get_all_settings(uid)
|
||||||
return jsonify(settings)
|
return jsonify(_masked(settings))
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.route("/search", methods=["GET"])
|
@settings_bp.route("/search", methods=["GET"])
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user