From 1c6823c05242a1284a01be63e416d08ce6244b12 Mon Sep 17 00:00:00 2001 From: Giancarmine Salucci Date: Sat, 28 Mar 2026 09:43:06 +0100 Subject: [PATCH] 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 --- src/routes/api/v1/libs/[id]/index/+server.ts | 23 +- .../api/v1/libs/[id]/index/server.test.ts | 182 ++++++++++ .../v1/libs/[id]/versions/discover/+server.ts | 63 ++++ .../[id]/versions/discover/server.test.ts | 160 +++++++++ src/routes/repos/[id]/+page.svelte | 334 +++++++++++++++++- 5 files changed, 741 insertions(+), 21 deletions(-) create mode 100644 src/routes/api/v1/libs/[id]/index/server.test.ts create mode 100644 src/routes/api/v1/libs/[id]/versions/discover/+server.ts create mode 100644 src/routes/api/v1/libs/[id]/versions/discover/server.test.ts diff --git a/src/routes/api/v1/libs/[id]/index/+server.ts b/src/routes/api/v1/libs/[id]/index/+server.ts index 0ecebb4..3101795 100644 --- a/src/routes/api/v1/libs/[id]/index/+server.ts +++ b/src/routes/api/v1/libs/[id]/index/+server.ts @@ -1,17 +1,23 @@ /** * POST /api/v1/libs/:id/index — trigger an indexing job for a repository. + * + * Also enqueues jobs for all registered versions so that re-indexing a repo + * automatically covers its secondary versions. */ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getClient } from '$lib/server/db/client'; import { IndexingJobMapper } from '$lib/server/mappers/indexing-job.mapper.js'; import { RepositoryService } from '$lib/server/services/repository.service'; +import { VersionService } from '$lib/server/services/version.service'; import { getQueue } from '$lib/server/pipeline/startup'; import { handleServiceError, NotFoundError } from '$lib/server/utils/validation'; export const POST: RequestHandler = async ({ params, request }) => { try { - const service = new RepositoryService(getClient()); + const db = getClient(); + const service = new RepositoryService(db); + const versionService = new VersionService(db); const id = decodeURIComponent(params.id); const repo = service.get(id); @@ -30,7 +36,20 @@ export const POST: RequestHandler = async ({ params, request }) => { const queue = getQueue(); const job = queue ? queue.enqueue(id, versionId) : service.createIndexingJob(id, versionId); - return json({ job: IndexingJobMapper.toDto(job) }, { status: 202 }); + // Also enqueue jobs for all registered versions (dedup in queue makes this safe). + // Only when this is a default-branch re-index (no explicit versionId requested). + let versionJobs: ReturnType[] = []; + if (!versionId) { + const versions = versionService.list(id); + versionJobs = versions.map((version) => { + const vJob = queue + ? queue.enqueue(id, version.id) + : service.createIndexingJob(id, version.id); + return IndexingJobMapper.toDto(vJob); + }); + } + + return json({ job: IndexingJobMapper.toDto(job), versionJobs }, { status: 202 }); } catch (err) { return handleServiceError(err); } diff --git a/src/routes/api/v1/libs/[id]/index/server.test.ts b/src/routes/api/v1/libs/[id]/index/server.test.ts new file mode 100644 index 0000000..6dfe321 --- /dev/null +++ b/src/routes/api/v1/libs/[id]/index/server.test.ts @@ -0,0 +1,182 @@ +/** + * Unit tests for POST /api/v1/libs/:id/index + * + * Verifies: + * - Default-branch re-index also enqueues jobs for all registered versions + * - versionJobs array is returned in the response + * - Explicit versionId request does NOT trigger extra version jobs + * - Returns 404 when repo does not exist + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { RepositoryService } from '$lib/server/services/repository.service'; +import { VersionService } from '$lib/server/services/version.service'; + +let db: Database.Database; +let mockQueue: { enqueue: ReturnType } | null = null; + +vi.mock('$lib/server/db/client', () => ({ + getClient: () => db +})); + +vi.mock('$lib/server/db/client.js', () => ({ + getClient: () => db +})); + +vi.mock('$lib/server/pipeline/startup', () => ({ + getQueue: () => mockQueue +})); + +vi.mock('$lib/server/pipeline/startup.js', () => ({ + getQueue: () => mockQueue +})); + +vi.mock('$lib/server/embeddings/registry', () => ({ + createProviderFromProfile: () => null +})); + +vi.mock('$lib/server/embeddings/registry.js', () => ({ + createProviderFromProfile: () => null +})); + +import { POST as postIndex } from './+server.js'; + +const NOW_S = Math.floor(Date.now() / 1000); + +function createTestDb(): Database.Database { + const client = new Database(':memory:'); + client.pragma('foreign_keys = ON'); + + const migrationsFolder = join(import.meta.dirname, '../../../../../../lib/server/db/migrations'); + const ftsFile = join(import.meta.dirname, '../../../../../../lib/server/db/fts.sql'); + + const migration0 = readFileSync(join(migrationsFolder, '0000_large_master_chief.sql'), 'utf-8'); + const migration1 = readFileSync(join(migrationsFolder, '0001_quick_nighthawk.sql'), 'utf-8'); + const migration2 = readFileSync(join(migrationsFolder, '0002_silky_stellaris.sql'), 'utf-8'); + + for (const migration of [migration0, migration1, migration2]) { + for (const stmt of migration + .split('--> statement-breakpoint') + .map((s) => s.trim()) + .filter(Boolean)) { + client.exec(stmt); + } + } + + client.exec(readFileSync(ftsFile, 'utf-8')); + return client; +} + +function makeEnqueueJob(repositoryId: string, versionId?: string) { + return { + id: `job-${Math.random().toString(36).slice(2)}`, + repositoryId, + versionId: versionId ?? null, + status: 'queued' as const, + processedFiles: 0, + totalFiles: 0, + error: null, + startedAt: null, + completedAt: null, + createdAt: new Date(NOW_S * 1000) + }; +} + +describe('POST /api/v1/libs/:id/index', () => { + beforeEach(() => { + db = createTestDb(); + mockQueue = null; + }); + + it('returns 404 when repo does not exist', async () => { + const response = await postIndex({ + params: { id: encodeURIComponent('/nonexistent/repo') }, + request: new Request('http://test', { method: 'POST' }) + } as never); + + expect(response.status).toBe(404); + }); + + it('returns job and empty versionJobs when no versions are registered', async () => { + const repoService = new RepositoryService(db); + repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + + const response = await postIndex({ + params: { id: encodeURIComponent('/facebook/react') }, + request: new Request('http://test', { method: 'POST' }) + } as never); + + expect(response.status).toBe(202); + const body = await response.json(); + expect(body.job).toBeDefined(); + expect(body.job.repositoryId).toBe('/facebook/react'); + expect(body.versionJobs).toEqual([]); + }); + + it('enqueues jobs for all registered versions on default-branch re-index', async () => { + const repoService = new RepositoryService(db); + const versionService = new VersionService(db); + + repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0'); + versionService.add('/facebook/react', 'v17.0.0', 'React v17.0.0'); + + const enqueue = vi.fn().mockImplementation( + (repositoryId: string, versionId?: string) => makeEnqueueJob(repositoryId, versionId) + ); + mockQueue = { enqueue }; + + const response = await postIndex({ + params: { id: encodeURIComponent('/facebook/react') }, + request: new Request('http://test', { method: 'POST' }) + } as never); + + expect(response.status).toBe(202); + const body = await response.json(); + + // Main job enqueued (no versionId) + expect(body.job).toBeDefined(); + expect(body.job.repositoryId).toBe('/facebook/react'); + + // Two version jobs enqueued + expect(body.versionJobs).toHaveLength(2); + expect(enqueue).toHaveBeenCalledTimes(3); // 1 main + 2 versions + + // Version IDs should be the registered version IDs + const enqueuedVersionIds = enqueue.mock.calls.slice(1).map((call) => call[1]); + expect(enqueuedVersionIds).toContain('/facebook/react/v18.3.0'); + expect(enqueuedVersionIds).toContain('/facebook/react/v17.0.0'); + }); + + it('does NOT enqueue version jobs when an explicit versionId is provided', async () => { + const repoService = new RepositoryService(db); + const versionService = new VersionService(db); + + repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0'); + + const enqueue = vi.fn().mockImplementation( + (repositoryId: string, versionId?: string) => makeEnqueueJob(repositoryId, versionId) + ); + mockQueue = { enqueue }; + + const response = await postIndex({ + params: { id: encodeURIComponent('/facebook/react') }, + request: new Request('http://test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ version: '/facebook/react/v18.3.0' }) + }) + } as never); + + expect(response.status).toBe(202); + const body = await response.json(); + + // Only one call — the explicit version, no extra version enumeration + expect(enqueue).toHaveBeenCalledTimes(1); + expect(body.versionJobs).toEqual([]); + }); +}); diff --git a/src/routes/api/v1/libs/[id]/versions/discover/+server.ts b/src/routes/api/v1/libs/[id]/versions/discover/+server.ts new file mode 100644 index 0000000..4952e65 --- /dev/null +++ b/src/routes/api/v1/libs/[id]/versions/discover/+server.ts @@ -0,0 +1,63 @@ +/** + * POST /api/v1/libs/:id/versions/discover — discover git tags for a local repository. + * + * Returns { tags: Array<{ tag: string; commitHash: string }> }. + * For GitHub repositories or when tag discovery fails, returns { tags: [] } (not an error). + * Returns 404 if the repository does not exist. + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getClient } from '$lib/server/db/client'; +import { RepositoryService } from '$lib/server/services/repository.service'; +import { VersionService } from '$lib/server/services/version.service'; +import { handleServiceError, NotFoundError } from '$lib/server/utils/validation'; + +function getServices() { + const db = getClient(); + return { + repoService: new RepositoryService(db), + versionService: new VersionService(db) + }; +} + +// --------------------------------------------------------------------------- +// POST /api/v1/libs/:id/versions/discover +// --------------------------------------------------------------------------- + +export const POST: RequestHandler = ({ params }) => { + try { + const { repoService, versionService } = getServices(); + const repositoryId = decodeURIComponent(params.id); + + const repo = repoService.get(repositoryId); + if (!repo) { + throw new NotFoundError(`Repository ${repositoryId} not found`); + } + + try { + const tags = versionService.discoverTags(repositoryId); + return json({ tags }); + } catch { + // GitHub repos or git errors — return empty tags gracefully + return json({ tags: [] }); + } + } catch (err) { + return handleServiceError(err); + } +}; + +// --------------------------------------------------------------------------- +// OPTIONS preflight +// --------------------------------------------------------------------------- + +export const OPTIONS: RequestHandler = () => { + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + } + }); +}; diff --git a/src/routes/api/v1/libs/[id]/versions/discover/server.test.ts b/src/routes/api/v1/libs/[id]/versions/discover/server.test.ts new file mode 100644 index 0000000..160edcc --- /dev/null +++ b/src/routes/api/v1/libs/[id]/versions/discover/server.test.ts @@ -0,0 +1,160 @@ +/** + * Unit tests for POST /api/v1/libs/:id/versions/discover + * + * Verifies: + * - Local repo returns discovered tags + * - GitHub repo returns empty tags gracefully (no error) + * - Non-existent repo returns 404 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +let db: Database.Database; + +vi.mock('$lib/server/db/client', () => ({ + getClient: () => db +})); + +vi.mock('$lib/server/db/client.js', () => ({ + getClient: () => db +})); + +vi.mock('$lib/server/pipeline/startup', () => ({ + getQueue: () => null +})); + +vi.mock('$lib/server/pipeline/startup.js', () => ({ + getQueue: () => null +})); + +// Mock git utilities so tests don't require a real git repo +vi.mock('$lib/server/utils/git', () => ({ + discoverVersionTags: vi.fn(), + resolveTagToCommit: vi.fn() +})); + +vi.mock('$lib/server/utils/git.js', () => ({ + discoverVersionTags: vi.fn(), + resolveTagToCommit: vi.fn() +})); + +import { POST as postDiscover } from './+server.js'; + +const NOW_S = Math.floor(Date.now() / 1000); + +function createTestDb(): Database.Database { + const client = new Database(':memory:'); + client.pragma('foreign_keys = ON'); + + const migrationsFolder = join(import.meta.dirname, '../../../../../../../lib/server/db/migrations'); + const ftsFile = join(import.meta.dirname, '../../../../../../../lib/server/db/fts.sql'); + + const migration0 = readFileSync(join(migrationsFolder, '0000_large_master_chief.sql'), 'utf-8'); + const migration1 = readFileSync(join(migrationsFolder, '0001_quick_nighthawk.sql'), 'utf-8'); + const migration2 = readFileSync(join(migrationsFolder, '0002_silky_stellaris.sql'), 'utf-8'); + + for (const migration of [migration0, migration1, migration2]) { + for (const stmt of migration + .split('--> statement-breakpoint') + .map((s) => s.trim()) + .filter(Boolean)) { + client.exec(stmt); + } + } + + client.exec(readFileSync(ftsFile, 'utf-8')); + return client; +} + +function seedRepo( + client: Database.Database, + overrides: { id?: string; source?: 'github' | 'local'; sourceUrl?: string } = {} +): string { + const id = overrides.id ?? '/facebook/react'; + client + .prepare( + `INSERT INTO repositories + (id, title, source, source_url, state, created_at, updated_at) + VALUES (?, ?, ?, ?, 'indexed', ?, ?)` + ) + .run( + id, + 'React', + overrides.source ?? 'github', + overrides.sourceUrl ?? 'https://github.com/facebook/react', + NOW_S, + NOW_S + ); + return id; +} + +describe('POST /api/v1/libs/:id/versions/discover', () => { + beforeEach(async () => { + db = createTestDb(); + const git = await import('$lib/server/utils/git'); + vi.mocked(git.discoverVersionTags).mockReset(); + vi.mocked(git.resolveTagToCommit).mockReset(); + }); + + it('returns 404 when repo does not exist', async () => { + const response = await postDiscover({ + params: { id: encodeURIComponent('/nonexistent/repo') } + } as never); + + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBeDefined(); + }); + + it('returns discovered tags for a local repository', async () => { + const { discoverVersionTags, resolveTagToCommit } = await import('$lib/server/utils/git'); + vi.mocked(discoverVersionTags).mockReturnValue(['v2.0.0', 'v1.0.0']); + vi.mocked(resolveTagToCommit).mockImplementation(({ tag }) => + tag === 'v2.0.0' ? 'abc12345' : 'def67890' + ); + + seedRepo(db, { source: 'local', sourceUrl: '/home/user/myrepo' }); + + const response = await postDiscover({ + params: { id: encodeURIComponent('/facebook/react') } + } as never); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.tags).toHaveLength(2); + expect(body.tags[0]).toEqual({ tag: 'v2.0.0', commitHash: 'abc12345' }); + expect(body.tags[1]).toEqual({ tag: 'v1.0.0', commitHash: 'def67890' }); + }); + + it('returns empty tags for a GitHub repository (no error)', async () => { + seedRepo(db, { source: 'github', sourceUrl: 'https://github.com/facebook/react' }); + + const response = await postDiscover({ + params: { id: encodeURIComponent('/facebook/react') } + } as never); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.tags).toEqual([]); + }); + + it('returns empty tags when git discovery throws', async () => { + const { discoverVersionTags } = await import('$lib/server/utils/git'); + vi.mocked(discoverVersionTags).mockImplementation(() => { + throw new Error('git command failed'); + }); + + seedRepo(db, { source: 'local', sourceUrl: '/home/user/myrepo' }); + + const response = await postDiscover({ + params: { id: encodeURIComponent('/facebook/react') } + } as never); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.tags).toEqual([]); + }); +}); diff --git a/src/routes/repos/[id]/+page.svelte b/src/routes/repos/[id]/+page.svelte index 056c3ca..cdb1b5a 100644 --- a/src/routes/repos/[id]/+page.svelte +++ b/src/routes/repos/[id]/+page.svelte @@ -1,6 +1,7 @@