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:
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
|
||||
import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
@@ -48,6 +49,7 @@ interface Project {
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
design_system_id: number | null;
|
||||
forge_connection_id: number | null;
|
||||
permission?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -472,12 +474,56 @@ async function refreshCoverage() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Forge pin (#2778) — which of the OWNER's connections serves this
|
||||
project. Owner-only UI: the select lists the viewer's own keyring, which
|
||||
is only the eligible set when the viewer IS the owner. ── */
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const isProjectOwner = computed(
|
||||
() => !!project.value && project.value.user_id === authStore.user?.id
|
||||
);
|
||||
interface ForgeConnectionOption { id: number; host: string; kind: string }
|
||||
const forgeOptions = ref<ForgeConnectionOption[]>([]);
|
||||
const forgePin = ref<number | null>(null);
|
||||
const savingForgePin = ref(false);
|
||||
|
||||
async function loadForgeOptions() {
|
||||
if (!isProjectOwner.value) return;
|
||||
try {
|
||||
const res = await apiGet<{ connections: ForgeConnectionOption[] }>(
|
||||
"/api/settings/forge-connections"
|
||||
);
|
||||
forgeOptions.value = res.connections;
|
||||
forgePin.value = project.value?.forge_connection_id ?? null;
|
||||
} catch {
|
||||
forgeOptions.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveForgePin() {
|
||||
savingForgePin.value = true;
|
||||
try {
|
||||
await apiPut(`/api/projects/${projectId.value}/forge`, {
|
||||
connection_id: forgePin.value,
|
||||
});
|
||||
if (project.value) project.value.forge_connection_id = forgePin.value;
|
||||
await loadCoverage();
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toast.show(body?.error || "Failed to change the project's forge", "error");
|
||||
forgePin.value = project.value?.forge_connection_id ?? null;
|
||||
} finally {
|
||||
savingForgePin.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProject();
|
||||
loadTasks();
|
||||
loadNotes();
|
||||
loadDesignSystems();
|
||||
loadCoverage();
|
||||
loadForgeOptions();
|
||||
});
|
||||
|
||||
/** Populate the design-system picker. Swallows failure on purpose: with no
|
||||
@@ -496,6 +542,7 @@ watch(projectId, async () => {
|
||||
loadTasks();
|
||||
loadNotes();
|
||||
loadCoverage();
|
||||
loadForgeOptions();
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -693,6 +740,23 @@ async function confirmDelete() {
|
||||
against recorded snippets.
|
||||
</p>
|
||||
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
|
||||
<!-- Forge pin (#2778): owner-only, because the eligible set is the
|
||||
owner's own keyring. Automatic = resolve by repo host. -->
|
||||
<div v-if="isProjectOwner && forgeOptions.length" class="coverage-forge-row">
|
||||
<label for="forge-pin" class="coverage-gaps-label">Forge</label>
|
||||
<select
|
||||
id="forge-pin"
|
||||
v-model="forgePin"
|
||||
class="input"
|
||||
:disabled="savingForgePin"
|
||||
@change="saveForgePin"
|
||||
>
|
||||
<option :value="null">Automatic (by repo host)</option>
|
||||
<option v-for="c in forgeOptions" :key="c.id" :value="c.id">
|
||||
{{ c.host }} ({{ c.kind }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-body">
|
||||
@@ -1202,6 +1266,18 @@ async function confirmDelete() {
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.coverage-forge-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.coverage-forge-row select {
|
||||
max-width: 20rem;
|
||||
}
|
||||
.coverage-gaps-label { color: var(--fs-text-tertiary); }
|
||||
.coverage-gap-chip {
|
||||
padding: 0.1rem 0.5rem;
|
||||
|
||||
Reference in New Issue
Block a user