feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
A forge token is a user's credential, not an instance's. The single admin-settings config is replaced by per-user keyring rows (one per forge host), and every server-side forge read runs on the PROJECT OWNER's keyring: - forge_connections table + projects.forge_connection_id pin (migration 0078, which also carries the existing admin config into the first admin's row and deletes the old setting keys — no legacy dual-read) - get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector; resolve(repo) picks the connection whose host serves the repo. A pinned project uses ONLY its pinned connection; a stale pin (ownership moved) is ignored, never honored across users - env FORGE_* config survives as an implicit entry for admin owners only; a stored row for the same host beats it - consumers threaded: pull-time freshness (owner of the note), coverage (owner of the project), coverage routes' configured flag - routes: /api/settings/forge-connections CRUD + per-connection test (own-rows only, tokens never returned); /api/admin/forge shrinks to /api/admin/forge-webhook (secret only); PUT /api/projects/<id>/forge pins, owner-or-admin asking, owner's connections only - UI: Git Forges card moves to Settings -> Integrations as a connection list; webhook secret stays in the admin Config tab; owner-only forge select on the project coverage card - backups exclude forge_connections (credentials, api_keys precedent) and the pin, so restores fall back to keyring resolution Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -420,15 +420,25 @@ 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: "", webhook_secret: "" });
|
||||
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);
|
||||
// Git forge connections (#2778) — the user's keyring: one read-only
|
||||
// credential per forge host, used server-side for every forge read on
|
||||
// projects this user owns. The token round-trips masked; the server treats
|
||||
// the mask as "unchanged".
|
||||
interface ForgeConnectionEntry {
|
||||
id: number; kind: string; base_url: string; host: string;
|
||||
}
|
||||
const forgeConnections = ref<ForgeConnectionEntry[]>([]);
|
||||
const forgeKinds = ref<string[]>(["gitea", "github"]);
|
||||
const connForm = ref({ id: 0, kind: "gitea", base_url: "", token: "" });
|
||||
const connFormOpen = ref(false);
|
||||
const savingConn = ref(false);
|
||||
const testingConnId = ref(0);
|
||||
const connTestResult = ref<{ id: number; ok: boolean; message: string } | null>(null);
|
||||
// The webhook secret stays admin: the push endpoint is one URL per instance
|
||||
// and authenticates deliveries, not users.
|
||||
const forgeWebhookSecret = ref("");
|
||||
const savingForgeWebhook = ref(false);
|
||||
const forgeWebhookSaved = ref(false);
|
||||
|
||||
|
||||
// Search test (SearXNG)
|
||||
@@ -576,25 +586,30 @@ onMounted(async () => {
|
||||
// base URL not configured yet
|
||||
}
|
||||
try {
|
||||
await loadForgeSettings();
|
||||
await loadForgeWebhook();
|
||||
} catch {
|
||||
// forge not configured yet
|
||||
// webhook secret not configured yet
|
||||
}
|
||||
}
|
||||
try {
|
||||
await loadForgeConnections();
|
||||
} catch {
|
||||
// no keyring yet — the ordinary state
|
||||
}
|
||||
_loadTabContent(activeTab.value);
|
||||
});
|
||||
|
||||
async function loadForgeSettings() {
|
||||
const cfg = await apiGet<{
|
||||
kind: string; base_url: string; token: string; webhook_secret: string;
|
||||
configured: boolean; kinds: string[];
|
||||
}>("/api/admin/forge");
|
||||
forge.value = {
|
||||
kind: cfg.kind, base_url: cfg.base_url, token: cfg.token,
|
||||
webhook_secret: cfg.webhook_secret,
|
||||
};
|
||||
forgeConfigured.value = cfg.configured;
|
||||
if (cfg.kinds?.length) forgeKinds.value = cfg.kinds;
|
||||
async function loadForgeConnections() {
|
||||
const res = await apiGet<{
|
||||
connections: ForgeConnectionEntry[]; kinds: string[];
|
||||
}>("/api/settings/forge-connections");
|
||||
forgeConnections.value = res.connections;
|
||||
if (res.kinds?.length) forgeKinds.value = res.kinds;
|
||||
}
|
||||
|
||||
async function loadForgeWebhook() {
|
||||
const cfg = await apiGet<{ webhook_secret: string }>("/api/admin/forge-webhook");
|
||||
forgeWebhookSecret.value = cfg.webhook_secret;
|
||||
}
|
||||
|
||||
async function changeEmail() {
|
||||
@@ -762,42 +777,77 @@ async function sendTestEmail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveForge() {
|
||||
savingForge.value = true;
|
||||
forgeSaved.value = false;
|
||||
forgeTestResult.value = null;
|
||||
function editConnection(c: ForgeConnectionEntry | null) {
|
||||
connTestResult.value = null;
|
||||
connFormOpen.value = true;
|
||||
connForm.value = c
|
||||
? { id: c.id, kind: c.kind, base_url: c.base_url, token: "********" }
|
||||
: { id: 0, kind: forgeKinds.value[0] || "gitea", base_url: "", token: "" };
|
||||
}
|
||||
|
||||
async function saveConnection() {
|
||||
savingConn.value = true;
|
||||
try {
|
||||
await apiPut("/api/admin/forge", forge.value);
|
||||
await loadForgeSettings();
|
||||
forgeSaved.value = true;
|
||||
setTimeout(() => (forgeSaved.value = false), 2000);
|
||||
const { id, ...values } = connForm.value;
|
||||
if (id) await apiPut(`/api/settings/forge-connections/${id}`, values);
|
||||
else await apiPost("/api/settings/forge-connections", values);
|
||||
connFormOpen.value = false;
|
||||
await loadForgeConnections();
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to save forge settings", "error");
|
||||
toastStore.show(body?.error || "Failed to save forge connection", "error");
|
||||
} finally {
|
||||
savingForge.value = false;
|
||||
savingConn.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testForge() {
|
||||
testingForge.value = true;
|
||||
forgeTestResult.value = null;
|
||||
async function removeConnection(id: number) {
|
||||
try {
|
||||
await apiDelete(`/api/settings/forge-connections/${id}`);
|
||||
connTestResult.value = null;
|
||||
await loadForgeConnections();
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to delete forge connection", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(id: number) {
|
||||
testingConnId.value = id;
|
||||
connTestResult.value = null;
|
||||
try {
|
||||
const res = await apiPost<{ version: string; username: string }>(
|
||||
"/api/admin/forge/test", {},
|
||||
`/api/settings/forge-connections/${id}/test`, {},
|
||||
);
|
||||
forgeTestResult.value = {
|
||||
ok: true,
|
||||
message: `Connected — Gitea ${res.version}, authenticated as ${res.username}`,
|
||||
connTestResult.value = {
|
||||
id, ok: true,
|
||||
message: `Connected — ${res.version}, authenticated as ${res.username}`,
|
||||
};
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
forgeTestResult.value = {
|
||||
ok: false,
|
||||
connTestResult.value = {
|
||||
id, ok: false,
|
||||
message: body?.error || "Connection test failed",
|
||||
};
|
||||
} finally {
|
||||
testingForge.value = false;
|
||||
testingConnId.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveForgeWebhook() {
|
||||
savingForgeWebhook.value = true;
|
||||
try {
|
||||
await apiPut("/api/admin/forge-webhook", {
|
||||
webhook_secret: forgeWebhookSecret.value,
|
||||
});
|
||||
await loadForgeWebhook();
|
||||
forgeWebhookSaved.value = true;
|
||||
setTimeout(() => (forgeWebhookSaved.value = false), 2000);
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toastStore.show(body?.error || "Failed to save webhook secret", "error");
|
||||
} finally {
|
||||
savingForgeWebhook.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1710,6 +1760,70 @@ function formatUserDate(iso: string): string {
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Git Forges</h2>
|
||||
<p class="section-desc">
|
||||
Read-only connections to your git forges, one per host. Projects you
|
||||
own use them server-side — resolved by repo host — to fetch and
|
||||
drift-check recorded snippets and measure pattern coverage. A
|
||||
read-scope token is enough. Gitea: an access token with read scope
|
||||
on repositories. GitHub: a fine-grained PAT with Contents:
|
||||
Read-only, base URL <code>https://github.com</code> (or your GitHub
|
||||
Enterprise URL).
|
||||
</p>
|
||||
<table v-if="forgeConnections.length" class="api-keys-table">
|
||||
<thead>
|
||||
<tr><th>Host</th><th>Kind</th><th>Base URL</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in forgeConnections" :key="c.id">
|
||||
<td>{{ c.host }}</td>
|
||||
<td>{{ c.kind }}</td>
|
||||
<td><code>{{ c.base_url }}</code></td>
|
||||
<td>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="testingConnId === c.id" @click="testConnection(c.id)">
|
||||
{{ testingConnId === c.id ? "Testing…" : "Test" }}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="editConnection(c)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="removeConnection(c.id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="section-desc not-configured">No forge connections yet.</p>
|
||||
<p
|
||||
v-if="connTestResult"
|
||||
:class="connTestResult.ok ? 'text-success' : 'text-error'"
|
||||
>
|
||||
{{ connTestResult.message }}
|
||||
</p>
|
||||
<div v-if="connFormOpen" class="smtp-grid">
|
||||
<div class="field">
|
||||
<label for="conn-kind">Forge</label>
|
||||
<select id="conn-kind" v-model="connForm.kind" class="input">
|
||||
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="conn-base-url">Base URL</label>
|
||||
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="input" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="conn-token">API Token (read scope)</label>
|
||||
<input id="conn-token" v-model="connForm.token" type="password" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<template v-if="connFormOpen">
|
||||
<button class="btn-primary" @click="saveConnection" :disabled="savingConn">
|
||||
{{ savingConn ? "Saving..." : connForm.id ? "Save Connection" : "Add Connection" }}
|
||||
</button>
|
||||
<button class="btn-ghost" @click="connFormOpen = false">Cancel</button>
|
||||
</template>
|
||||
<button v-else class="btn-primary" @click="editConnection(null)">Add Connection</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Data ── -->
|
||||
@@ -2161,61 +2275,26 @@ function formatUserDate(iso: string): string {
|
||||
</section>
|
||||
|
||||
<section class="settings-section full-width">
|
||||
<h2>Git Forge</h2>
|
||||
<h2>Forge Webhook</h2>
|
||||
<p class="section-desc">
|
||||
Optional read-only connection to your git forge (Gitea or GitHub) so
|
||||
snippet code can be fetched, drift-checked, and coverage-measured
|
||||
server-side. A read-scope token is enough. Leave the kind unset to
|
||||
keep the integration off.
|
||||
Forge connections are per-user (Settings → Integrations → Git
|
||||
Forges). What stays instance-wide is the push webhook: create one on
|
||||
the forge pointing at <code>/api/webhooks/forge</code> with this
|
||||
secret, and snippets whose recorded files change get flagged for
|
||||
re-verification.
|
||||
</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" />
|
||||
<p class="field-hint">
|
||||
Gitea: an access token with read scope on repositories. GitHub:
|
||||
a fine-grained PAT with Contents: Read-only (or a classic token
|
||||
with repo read). For GitHub, use
|
||||
<code>https://github.com</code> as the base URL — or your
|
||||
GitHub Enterprise instance's URL.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="forge-webhook-secret">Webhook Secret</label>
|
||||
<input id="forge-webhook-secret" v-model="forge.webhook_secret" type="password" class="input" />
|
||||
<p class="field-hint">
|
||||
Optional: create a push webhook on the forge pointing at
|
||||
<code>/api/webhooks/forge</code> with this secret, and snippets
|
||||
whose recorded files change get flagged for re-verification.
|
||||
</p>
|
||||
<input id="forge-webhook-secret" v-model="forgeWebhookSecret" 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" }}
|
||||
<div class="actions">
|
||||
<button class="btn-primary" @click="saveForgeWebhook" :disabled="savingForgeWebhook">
|
||||
{{ savingForgeWebhook ? "Saving..." : "Save Webhook Secret" }}
|
||||
</button>
|
||||
<button class="btn-ghost" @click="testForge" :disabled="testingForge || !forgeConfigured">
|
||||
{{ testingForge ? "Testing..." : "Test Connection" }}
|
||||
</button>
|
||||
<span v-if="forgeSaved" class="saved-msg">Saved!</span>
|
||||
<span v-if="forgeWebhookSaved" class="saved-msg">Saved!</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="forgeTestResult"
|
||||
:class="forgeTestResult.ok ? 'text-success' : 'text-error'"
|
||||
>
|
||||
{{ forgeTestResult.message }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user