Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
CodeNavigationIndexingError,
CodeNavigationRefNotFoundError,
CodeNavigationServiceImpl,
CodeNavigationTargetNotFoundError,
} from "./code-navigation-service.js";
import { createMockTokenProvider } from "./test-helpers.js";

Expand Down Expand Up @@ -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(
Expand Down
42 changes: 32 additions & 10 deletions packages/core-internal/src/services/code-navigation-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
);
Expand All @@ -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,
Expand Down Expand Up @@ -2753,6 +2755,26 @@ function parseSuggestedRefs(
return parseAvailableArtifacts(raw);
}

function parseGraphQLRepoUrl(
extensions: Record<string, unknown> | undefined,
): string | undefined {
return typeof extensions?.repo_url === "string"
? extensions.repo_url
: typeof extensions?.repoUrl === "string"
? extensions.repoUrl
: undefined;
}

function parseGraphQLGitRef(
extensions: Record<string, unknown> | undefined,
): string | undefined {
return typeof extensions?.git_ref === "string"
? extensions.git_ref
: typeof extensions?.gitRef === "string"
? extensions.gitRef
: undefined;
}

function parseTargetResolution(
extensions: Record<string, unknown> | undefined,
): TargetResolution | undefined {
Expand Down
34 changes: 34 additions & 0 deletions packages/mcp/src/shared/code-navigation-error-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 9 additions & 3 deletions packages/mcp/src/shared/code-navigation-error-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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":
Expand Down
22 changes: 22 additions & 0 deletions packages/mcp/src/shared/unified-search-response.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";
import {
CodeNavigationRefNotFoundError,
CodeNavigationTargetNotFoundError,
type UnifiedSearchOutcome,
type UnifiedSearchParams,
} from "@githits/core-internal";
Expand Down Expand Up @@ -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", () => {
Expand Down
21 changes: 17 additions & 4 deletions scripts/validate-public-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface PackFile {
}

interface PackResult {
filename: string;
filename?: string;
files?: PackFile[];
}

Expand Down Expand Up @@ -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(
Expand Down