fix(MULTIVERSION-0001): fix version isolation, 404 on unknown version, commit-hash lookup, and searchModeUsed
Bug 1: Thread version tag from run() into crawl() via getVersionTag() helper so
LocalCrawler and GithubCrawler receive the correct ref when indexing a named
version instead of always crawling HEAD.
Bug 2: Return HTTP 404 with code VERSION_NOT_FOUND when a requested version tag
is not found in repository_versions, instead of silently falling back to a
cross-version mixed result set.
Bug 4: Before returning 404, attempt a commit_hash prefix match (min 7 chars)
so callers can request a version by full or short SHA.
Bug 3: Change HybridSearchService.search() to return
{ results, searchModeUsed } and propagate searchModeUsed through
ContextResponseMetadata and ContextJsonResponseDto so callers can see which
strategy (keyword / semantic / hybrid / keyword_fallback) was actually used.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -486,4 +486,83 @@ describe('API contract integration', () => {
|
||||
isLocal: false
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /api/v1/context returns 404 with VERSION_NOT_FOUND when version does not exist', async () => {
|
||||
const repositoryId = seedRepo(db);
|
||||
|
||||
const response = await getContext({
|
||||
url: new URL(
|
||||
`http://test/api/v1/context?libraryId=${encodeURIComponent(`${repositoryId}/v99.0.0`)}&query=${encodeURIComponent('foo')}`
|
||||
)
|
||||
} as never);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe('VERSION_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('GET /api/v1/context resolves a version by full commit SHA', async () => {
|
||||
const repositoryId = seedRepo(db);
|
||||
const fullSha = 'a'.repeat(40);
|
||||
|
||||
// Insert version with a commit_hash
|
||||
db.prepare(
|
||||
`INSERT INTO repository_versions
|
||||
(id, repository_id, tag, commit_hash, state, total_snippets, indexed_at, created_at)
|
||||
VALUES (?, ?, ?, ?, 'indexed', 0, ?, ?)`
|
||||
).run(`${repositoryId}/v2.0.0`, repositoryId, 'v2.0.0', fullSha, NOW_S, NOW_S);
|
||||
|
||||
const response = await getContext({
|
||||
url: new URL(
|
||||
`http://test/api/v1/context?libraryId=${encodeURIComponent(`${repositoryId}/${fullSha}`)}&query=${encodeURIComponent('anything')}`
|
||||
)
|
||||
} as never);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.version?.resolved).toBe('v2.0.0');
|
||||
});
|
||||
|
||||
it('GET /api/v1/context resolves a version by short SHA prefix (8 chars)', async () => {
|
||||
const repositoryId = seedRepo(db);
|
||||
const fullSha = 'b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0';
|
||||
const shortSha = fullSha.slice(0, 8);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO repository_versions
|
||||
(id, repository_id, tag, commit_hash, state, total_snippets, indexed_at, created_at)
|
||||
VALUES (?, ?, ?, ?, 'indexed', 0, ?, ?)`
|
||||
).run(`${repositoryId}/v3.0.0`, repositoryId, 'v3.0.0', fullSha, NOW_S, NOW_S);
|
||||
|
||||
const response = await getContext({
|
||||
url: new URL(
|
||||
`http://test/api/v1/context?libraryId=${encodeURIComponent(`${repositoryId}/${shortSha}`)}&query=${encodeURIComponent('anything')}`
|
||||
)
|
||||
} as never);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.version?.resolved).toBe('v3.0.0');
|
||||
});
|
||||
|
||||
it('GET /api/v1/context includes searchModeUsed in JSON response', async () => {
|
||||
const repositoryId = seedRepo(db);
|
||||
const documentId = seedDocument(db, repositoryId);
|
||||
seedSnippet(db, {
|
||||
documentId,
|
||||
repositoryId,
|
||||
content: 'search mode used test snippet'
|
||||
});
|
||||
|
||||
const response = await getContext({
|
||||
url: new URL(
|
||||
`http://test/api/v1/context?libraryId=${encodeURIComponent(repositoryId)}&query=${encodeURIComponent('search mode used')}`
|
||||
)
|
||||
} as never);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.searchModeUsed).toBeDefined();
|
||||
expect(['keyword', 'semantic', 'hybrid', 'keyword_fallback']).toContain(body.searchModeUsed);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,6 +198,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
let versionId: string | undefined;
|
||||
let resolvedVersion: RawVersionRow | undefined;
|
||||
if (parsed.version) {
|
||||
// Try exact tag match first.
|
||||
resolvedVersion = db
|
||||
.prepare<
|
||||
[string, string],
|
||||
@@ -205,12 +206,33 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
>(`SELECT id, tag FROM repository_versions WHERE repository_id = ? AND tag = ?`)
|
||||
.get(parsed.repositoryId, parsed.version);
|
||||
|
||||
// Version not found is not fatal — fall back to default branch.
|
||||
versionId = resolvedVersion?.id;
|
||||
// Fall back to commit hash prefix match (min 7 chars).
|
||||
if (!resolvedVersion && parsed.version.length >= 7) {
|
||||
resolvedVersion = db
|
||||
.prepare<
|
||||
[string, string],
|
||||
RawVersionRow
|
||||
>(
|
||||
`SELECT id, tag FROM repository_versions
|
||||
WHERE repository_id = ? AND commit_hash LIKE ?`
|
||||
)
|
||||
.get(parsed.repositoryId, `${parsed.version}%`);
|
||||
}
|
||||
|
||||
if (!resolvedVersion) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: `Version ${parsed.version} not found for library ${parsed.repositoryId}`,
|
||||
code: 'VERSION_NOT_FOUND'
|
||||
}),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json', ...CORS_HEADERS } }
|
||||
);
|
||||
}
|
||||
versionId = resolvedVersion.id;
|
||||
}
|
||||
|
||||
// Execute hybrid search (falls back to FTS5 when no embedding provider is set).
|
||||
const searchResults = await hybridService.search(query, {
|
||||
const { results: searchResults, searchModeUsed } = await hybridService.search(query, {
|
||||
repositoryId: parsed.repositoryId,
|
||||
versionId,
|
||||
limit: 50, // fetch more than needed; token budget will trim
|
||||
@@ -242,6 +264,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
const metadata: ContextResponseMetadata = {
|
||||
localSource: repo.source === 'local',
|
||||
resultCount: selectedResults.length,
|
||||
searchModeUsed,
|
||||
repository: {
|
||||
id: repo.id,
|
||||
title: repo.title,
|
||||
|
||||
Reference in New Issue
Block a user