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 -->