feat(MULTIVERSION-0001): add version management UI and auto-enqueue versions on re-index

- Add POST /api/v1/libs/:id/versions/discover endpoint that calls
  versionService.discoverTags() for local repos and returns empty tags
  gracefully for GitHub repos or git failures
- Enhance POST /api/v1/libs/:id/index to also enqueue jobs for all
  registered versions on default-branch re-index, returning versionJobs
  in the response
- Replace read-only Indexed Versions section with interactive Versions
  panel in the repo detail page: per-version state badges, Index/Remove
  buttons, inline Add version form, and Discover tags flow for local repos
- Add unit tests for both new/changed backend endpoints (8 new test cases)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-03-28 09:43:06 +01:00
parent 1c5b634ea4
commit 1c6823c052
5 changed files with 741 additions and 21 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve as resolveRoute } from '$app/paths';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import type { Repository, IndexingJob } from '$lib/types';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@@ -25,6 +26,35 @@
let errorMessage = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Version management state
interface VersionDto {
id: string;
repositoryId: string;
tag: string;
title: string | null;
commitHash: string | null;
state: 'pending' | 'indexing' | 'indexed' | 'error';
totalSnippets: number;
indexedAt: string | null;
createdAt: string;
}
let versions = $state<VersionDto[]>([]);
let versionsLoading = $state(false);
// Add version form
let addVersionTag = $state('');
let addVersionBusy = $state(false);
// Discover tags state
let discoverBusy = $state(false);
let discoveredTags = $state<Array<{ tag: string; commitHash: string }>>([]);
let selectedDiscoveredTags = $state<Set<string>>(new Set());
let showDiscoverPanel = $state(false);
let registerBusy = $state(false);
// Remove confirm
let removeTag = $state<string | null>(null);
const stateColors: Record<string, string> = {
pending: 'bg-gray-100 text-gray-600',
indexing: 'bg-blue-100 text-blue-700',
@@ -50,6 +80,25 @@
}
}
async function loadVersions() {
versionsLoading = true;
try {
const res = await fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}/versions`);
if (res.ok) {
const data = await res.json();
versions = data.versions ?? [];
}
} catch {
// ignore
} finally {
versionsLoading = false;
}
}
onMount(() => {
loadVersions();
});
async function handleReindex() {
errorMessage = null;
successMessage = null;
@@ -65,8 +114,12 @@
if (d.job?.id) {
activeJobId = d.job.id;
}
successMessage = 'Re-indexing started.';
await refreshRepo();
const versionCount = d.versionJobs?.length ?? 0;
successMessage =
versionCount > 0
? `Re-indexing started. Also queued ${versionCount} version job${versionCount === 1 ? '' : 's'}.`
: 'Re-indexing started.';
await Promise.all([refreshRepo(), loadVersions()]);
} catch (e) {
errorMessage = (e as Error).message;
}
@@ -89,12 +142,133 @@
}
}
async function handleAddVersion() {
const tag = addVersionTag.trim();
if (!tag) return;
addVersionBusy = true;
errorMessage = null;
try {
const res = await fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag, autoIndex: true })
});
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? 'Failed to add version');
}
addVersionTag = '';
await loadVersions();
} catch (e) {
errorMessage = (e as Error).message;
} finally {
addVersionBusy = false;
}
}
async function handleIndexVersion(tag: string) {
errorMessage = null;
try {
const res = await fetch(
`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/${encodeURIComponent(tag)}/index`,
{ method: 'POST' }
);
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? 'Failed to queue version indexing');
}
await loadVersions();
} catch (e) {
errorMessage = (e as Error).message;
}
}
async function handleRemoveVersion() {
if (!removeTag) return;
const tag = removeTag;
removeTag = null;
errorMessage = null;
try {
const res = await fetch(
`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/${encodeURIComponent(tag)}`,
{ method: 'DELETE' }
);
if (!res.ok && res.status !== 204) {
const d = await res.json();
throw new Error(d.error ?? 'Failed to remove version');
}
await loadVersions();
} catch (e) {
errorMessage = (e as Error).message;
}
}
async function handleDiscoverTags() {
discoverBusy = true;
errorMessage = null;
try {
const res = await fetch(
`/api/v1/libs/${encodeURIComponent(repo.id)}/versions/discover`,
{ method: 'POST' }
);
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? 'Failed to discover tags');
}
const d = await res.json();
const registeredTags = new Set(versions.map((v) => v.tag));
discoveredTags = (d.tags ?? []).filter(
(t: { tag: string; commitHash: string }) => !registeredTags.has(t.tag)
);
selectedDiscoveredTags = new Set(discoveredTags.map((t) => t.tag));
showDiscoverPanel = true;
} catch (e) {
errorMessage = (e as Error).message;
} finally {
discoverBusy = false;
}
}
function toggleDiscoveredTag(tag: string) {
const next = new Set(selectedDiscoveredTags);
if (next.has(tag)) {
next.delete(tag);
} else {
next.add(tag);
}
selectedDiscoveredTags = next;
}
async function handleRegisterSelected() {
if (selectedDiscoveredTags.size === 0) return;
registerBusy = true;
errorMessage = null;
try {
await Promise.all(
[...selectedDiscoveredTags].map((tag) =>
fetch(`/api/v1/libs/${encodeURIComponent(repo.id)}/versions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag, autoIndex: true })
})
)
);
showDiscoverPanel = false;
discoveredTags = [];
selectedDiscoveredTags = new Set();
await loadVersions();
} catch (e) {
errorMessage = (e as Error).message;
} finally {
registerBusy = false;
}
}
function formatDate(ts: Date | number | string | null | undefined): string {
if (!ts) return 'Never';
return new Date(ts as string).toLocaleString();
}
const indexedVersions = $derived(repo.indexedVersions ?? []);
const embeddingCount = $derived(repo.embeddingCount ?? 0);
const totalSnippets = $derived(repo.totalSnippets ?? 0);
const totalTokens = $derived(repo.totalTokens ?? 0);
@@ -191,6 +365,133 @@
{/if}
</div>
<!-- Versions -->
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-5">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 class="text-sm font-semibold text-gray-700">Versions</h2>
<div class="flex flex-wrap items-center gap-2">
<!-- Add version inline form -->
<form
onsubmit={(e) => {
e.preventDefault();
handleAddVersion();
}}
class="flex items-center gap-1.5"
>
<input
type="text"
bind:value={addVersionTag}
placeholder="e.g. v2.0.0"
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-400 focus:outline-none"
/>
<button
type="submit"
disabled={addVersionBusy || !addVersionTag.trim()}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Add
</button>
</form>
<!-- Discover tags button — local repos only -->
{#if repo.source === 'local'}
<button
onclick={handleDiscoverTags}
disabled={discoverBusy}
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{discoverBusy ? 'Discovering...' : 'Discover tags'}
</button>
{/if}
</div>
</div>
<!-- Discover panel -->
{#if showDiscoverPanel}
<div class="mb-4 rounded-lg border border-blue-100 bg-blue-50 p-4">
<div class="mb-2 flex items-center justify-between">
<p class="text-sm font-medium text-blue-700">
{discoveredTags.length === 0
? 'No new tags found'
: `${discoveredTags.length} new tag${discoveredTags.length === 1 ? '' : 's'} available`}
</p>
<button
onclick={() => {
showDiscoverPanel = false;
discoveredTags = [];
selectedDiscoveredTags = new Set();
}}
class="text-xs text-blue-600 hover:underline"
>
Close
</button>
</div>
{#if discoveredTags.length > 0}
<div class="mb-3 flex flex-col gap-1.5">
{#each discoveredTags as discovered (discovered.tag)}
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input
type="checkbox"
checked={selectedDiscoveredTags.has(discovered.tag)}
onchange={() => toggleDiscoveredTag(discovered.tag)}
class="rounded border-gray-300"
/>
<span class="font-mono text-gray-800">{discovered.tag}</span>
<span class="font-mono text-xs text-gray-400">{discovered.commitHash.slice(0, 8)}</span>
</label>
{/each}
</div>
<button
onclick={handleRegisterSelected}
disabled={registerBusy || selectedDiscoveredTags.size === 0}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{registerBusy
? 'Registering...'
: `Register ${selectedDiscoveredTags.size} selected`}
</button>
{/if}
</div>
{/if}
<!-- Versions list -->
{#if versionsLoading}
<p class="text-sm text-gray-400">Loading versions...</p>
{:else if versions.length === 0}
<p class="text-sm text-gray-400">No versions registered. Add a tag above to get started.</p>
{:else}
<div class="divide-y divide-gray-100">
{#each versions as version (version.id)}
<div class="flex items-center justify-between py-2.5">
<div class="flex items-center gap-3">
<span class="font-mono text-sm font-medium text-gray-900">{version.tag}</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[version.state] ??
'bg-gray-100 text-gray-600'}"
>
{stateLabels[version.state] ?? version.state}
</span>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => handleIndexVersion(version.tag)}
disabled={version.state === 'indexing'}
class="rounded-lg border border-blue-200 px-3 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50"
>
{version.state === 'indexing' ? 'Indexing...' : 'Index'}
</button>
<button
onclick={() => (removeTag = version.tag)}
class="rounded-lg border border-red-100 px-3 py-1 text-xs font-medium text-red-500 hover:bg-red-50"
>
Remove
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Metadata -->
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-5">
<h2 class="mb-3 text-sm font-semibold text-gray-700">Repository Info</h2>
@@ -214,22 +515,6 @@
</dl>
</div>
<!-- Indexed Versions -->
{#if indexedVersions.length > 0}
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-5">
<h2 class="mb-3 text-sm font-semibold text-gray-700">Indexed Versions</h2>
<div class="flex flex-wrap gap-2">
{#each indexedVersions as versionTag (versionTag)}
<span
class="rounded-full border border-green-200 bg-green-50 px-3 py-1 font-mono text-sm text-green-800"
>
{versionTag}
</span>
{/each}
</div>
</div>
{/if}
<!-- Recent Jobs -->
{#if recentJobs.length > 0}
<div class="mt-6 rounded-xl border border-gray-200 bg-white p-5">
@@ -277,3 +562,14 @@
onCancel={() => (showDeleteConfirm = false)}
/>
{/if}
{#if removeTag}
<ConfirmDialog
title="Remove Version"
message="Remove version '{removeTag}'? This will delete all indexed snippets for this version."
confirmLabel="Remove"
danger={true}
onConfirm={handleRemoveVersion}
onCancel={() => (removeTag = null)}
/>
{/if}