diff --git a/packages/core-internal/src/services/code-navigation-service.test.ts b/packages/core-internal/src/services/code-navigation-service.test.ts index 3d5bb5f4..bc2103c8 100644 --- a/packages/core-internal/src/services/code-navigation-service.test.ts +++ b/packages/core-internal/src/services/code-navigation-service.test.ts @@ -14,6 +14,7 @@ import { CodeNavigationIndexingError, CodeNavigationRefNotFoundError, CodeNavigationServiceImpl, + CodeNavigationTargetNotFoundError, } from "./code-navigation-service.js"; import { createMockTokenProvider } from "./test-helpers.js"; @@ -1067,6 +1068,50 @@ describe("CodeNavigationServiceImpl", () => { } }); + it("classifies GraphQL REPOSITORY_NOT_FOUND as target not found with repository details", async () => { + mockFetch(() => + Promise.resolve( + new Response( + JSON.stringify({ + errors: [ + { + message: + "Repository not found or inaccessible: https://github.com/acme/missing.", + extensions: { + code: "REPOSITORY_NOT_FOUND", + retryable: false, + repo_url: "https://github.com/acme/missing", + git_ref: "main", + }, + }, + ], + }), + { status: 200 }, + ), + ), + ); + const service = new CodeNavigationServiceImpl( + BASE_URL, + createMockTokenProvider(), + ); + + try { + await service.search({ + targets: [ + { repoUrl: "https://github.com/acme/missing", gitRef: "main" }, + ], + query: "router middleware", + }); + throw new Error("expected REPOSITORY_NOT_FOUND"); + } catch (error) { + expect(error).toBeInstanceOf(CodeNavigationTargetNotFoundError); + expect(error).not.toBeInstanceOf(CodeNavigationBackendError); + const typed = error as CodeNavigationTargetNotFoundError; + expect(typed.repoUrl).toBe("https://github.com/acme/missing"); + expect(typed.requestedRef).toBe("main"); + } + }); + it("classifies GraphQL schema mismatch as backend protocol error", async () => { mockFetch(() => Promise.resolve( diff --git a/packages/core-internal/src/services/code-navigation-service.ts b/packages/core-internal/src/services/code-navigation-service.ts index 7f5959da..b80deccc 100644 --- a/packages/core-internal/src/services/code-navigation-service.ts +++ b/packages/core-internal/src/services/code-navigation-service.ts @@ -539,6 +539,8 @@ export class CodeNavigationTargetNotFoundError extends Error { constructor( message: string, public readonly availableVersions?: AvailableVersion[], + public readonly repoUrl?: string, + public readonly requestedRef?: string, ) { super(message); this.name = "CodeNavigationTargetNotFoundError"; @@ -2015,16 +2017,8 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { case "REF_NOT_FOUND": return new CodeNavigationRefNotFoundError( message, - typeof extensions?.repo_url === "string" - ? extensions.repo_url - : typeof extensions?.repoUrl === "string" - ? extensions.repoUrl - : undefined, - typeof extensions?.git_ref === "string" - ? extensions.git_ref - : typeof extensions?.gitRef === "string" - ? extensions.gitRef - : undefined, + parseGraphQLRepoUrl(extensions), + parseGraphQLGitRef(extensions), parseAvailableRefs(extensions), parseSuggestedRefs(extensions), ); @@ -2034,6 +2028,14 @@ export class CodeNavigationServiceImpl implements CodeNavigationService { case "NO_REPOSITORY_URL": return new CodeNavigationTargetNotFoundError(message); + case "REPOSITORY_NOT_FOUND": + return new CodeNavigationTargetNotFoundError( + message, + undefined, + parseGraphQLRepoUrl(extensions), + parseGraphQLGitRef(extensions), + ); + case "FILE_NOT_FOUND": return new CodeNavigationFileNotFoundError( message, @@ -2753,6 +2755,26 @@ function parseSuggestedRefs( return parseAvailableArtifacts(raw); } +function parseGraphQLRepoUrl( + extensions: Record | undefined, +): string | undefined { + return typeof extensions?.repo_url === "string" + ? extensions.repo_url + : typeof extensions?.repoUrl === "string" + ? extensions.repoUrl + : undefined; +} + +function parseGraphQLGitRef( + extensions: Record | undefined, +): string | undefined { + return typeof extensions?.git_ref === "string" + ? extensions.git_ref + : typeof extensions?.gitRef === "string" + ? extensions.gitRef + : undefined; +} + function parseTargetResolution( extensions: Record | undefined, ): TargetResolution | undefined { diff --git a/packages/mcp/src/shared/code-navigation-error-map.test.ts b/packages/mcp/src/shared/code-navigation-error-map.test.ts index b986e136..76090d24 100644 --- a/packages/mcp/src/shared/code-navigation-error-map.test.ts +++ b/packages/mcp/src/shared/code-navigation-error-map.test.ts @@ -71,6 +71,24 @@ describe("mapCodeNavigationError", () => { }); }); + it("classifies repository CodeNavigationTargetNotFoundError with repository details", () => { + const err = new CodeNavigationTargetNotFoundError( + "Repository not found or inaccessible", + undefined, + "https://github.com/acme/missing", + "main", + ); + expect(mapCodeNavigationError(err)).toEqual({ + code: "NOT_FOUND", + message: "Repository not found or inaccessible", + retryable: false, + details: { + repoUrl: "https://github.com/acme/missing", + requestedRef: "main", + }, + }); + }); + it("classifies CodeNavigationVersionNotFoundError as VERSION_NOT_FOUND with structured details", () => { const err = new CodeNavigationVersionNotFoundError( 'No version of npm/express matches "4". Available versions: 5.2.1, 5.1.0. Try: express@5.2.1.', @@ -297,6 +315,22 @@ describe("mapCodeNavigationError", () => { }); }); + it("classifies CodeNavigationBackendError with REPOSITORY_NOT_FOUND as NOT_FOUND", () => { + const err = new CodeNavigationBackendError( + "Repository not found or inaccessible", + undefined, + "REPOSITORY_NOT_FOUND", + false, + ); + + expect(mapCodeNavigationError(err)).toEqual({ + code: "NOT_FOUND", + message: "Repository not found or inaccessible", + retryable: false, + details: { graphqlCode: "REPOSITORY_NOT_FOUND" }, + }); + }); + it("classifies CodeNavigationBackendError with UPSTREAM_ERROR as BACKEND_ERROR (retryable default)", () => { const err = new CodeNavigationBackendError( "Upstream failed", diff --git a/packages/mcp/src/shared/code-navigation-error-map.ts b/packages/mcp/src/shared/code-navigation-error-map.ts index 2e5a5d8e..e29ffe5b 100644 --- a/packages/mcp/src/shared/code-navigation-error-map.ts +++ b/packages/mcp/src/shared/code-navigation-error-map.ts @@ -141,13 +141,17 @@ function classify(error: unknown): MappedError { }; } if (error instanceof CodeNavigationTargetNotFoundError) { + const details: MappedErrorDetails = {}; + if (error.availableVersions && error.availableVersions.length > 0) { + details.availableVersions = error.availableVersions; + } + if (error.repoUrl) details.repoUrl = error.repoUrl; + if (error.requestedRef) details.requestedRef = error.requestedRef; return { code: "NOT_FOUND", message: error.message, retryable: false, - details: error.availableVersions - ? { availableVersions: error.availableVersions } - : undefined, + details: Object.keys(details).length > 0 ? details : undefined, }; } if (error instanceof CodeNavigationRefNotFoundError) { @@ -313,6 +317,8 @@ function classifyBackendError(error: CodeNavigationBackendError): MappedError { return build("TIMEOUT", true); case "RATE_LIMITED": return build("RATE_LIMITED", true); + case "REPOSITORY_NOT_FOUND": + return build("NOT_FOUND", false); case "REF_NOT_FOUND": return build("REF_NOT_FOUND", false); case "UPSTREAM_ERROR": diff --git a/packages/mcp/src/shared/unified-search-response.test.ts b/packages/mcp/src/shared/unified-search-response.test.ts index 01babcbc..3172fddc 100644 --- a/packages/mcp/src/shared/unified-search-response.test.ts +++ b/packages/mcp/src/shared/unified-search-response.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { CodeNavigationRefNotFoundError, + CodeNavigationTargetNotFoundError, type UnifiedSearchOutcome, type UnifiedSearchParams, } from "@githits/core-internal"; @@ -1100,6 +1101,27 @@ describe("buildUnifiedSearchErrorPayload", () => { }, }); }); + + it("includes repository NOT_FOUND details for missing repositories", () => { + const payload = buildUnifiedSearchErrorPayload( + new CodeNavigationTargetNotFoundError( + "Repository not found or inaccessible", + undefined, + "https://github.com/acme/missing", + "main", + ), + ); + + expect(payload).toEqual({ + error: "Repository not found or inaccessible", + code: "NOT_FOUND", + retryable: false, + details: { + repoUrl: "https://github.com/acme/missing", + requestedRef: "main", + }, + }); + }); }); describe("buildUnifiedSearchStatusPayload", () => { diff --git a/scripts/validate-public-packages.ts b/scripts/validate-public-packages.ts index b816b064..2bf655b8 100644 --- a/scripts/validate-public-packages.ts +++ b/scripts/validate-public-packages.ts @@ -16,7 +16,7 @@ interface PackFile { } interface PackResult { - filename: string; + filename?: string; files?: PackFile[]; } @@ -196,10 +196,23 @@ async function packPackage( ); const packResults = JSON.parse(output) as PackResult[]; const filename = packResults[0]?.filename; - if (!filename) { - throw new Error(`npm pack did not return a filename for ${packageInfo.id}`); + if (filename) { + return join(packDirectory, basename(filename)); } - return join(packDirectory, basename(filename)); + + const tarballs = (await readdir(packDirectory)).filter((entry) => + entry.endsWith(".tgz"), + ); + if (tarballs.length !== 1) { + throw new Error( + `npm pack did not return a filename for ${packageInfo.id} and created ${tarballs.length} tarballs`, + ); + } + const tarball = tarballs[0]; + if (!tarball) { + throw new Error(`npm pack did not create a tarball for ${packageInfo.id}`); + } + return join(packDirectory, tarball); } async function extractPackage(