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
48 changes: 41 additions & 7 deletions src/core/skill-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
* mentions `<thing>` — the path-targeted query finds them when the bare
* content query can't.
*
* Auth comes from `GITHUB_TOKEN` when present; unauthenticated requests are
* subject to the public Code Search rate limit (10 req/min). See cli/cli
* issue #13293 for upstream tracking.
* Auth is resolved by `resolveGhToken`: env vars first, then `gh auth token`
* so that credentials from `gh auth login` are picked up automatically. See
* cli/cli issue #13293 for upstream tracking.
*/

const OWNER_REGEX = /^[A-Za-z0-9-]{1,39}$/;
Expand Down Expand Up @@ -166,6 +166,12 @@ function classifyApiError(status: number, body: unknown): SkillSearchError {
const msg = typeof body === 'object' && body !== null && 'message' in body
? String((body as { message: unknown }).message ?? '')
: '';
if (status === 401) {
return new SkillSearchError(
'GitHub Code Search requires authentication. Run `gh auth login` or set GITHUB_TOKEN.',
'api',
);
}
if (status === 403 && /rate limit/i.test(msg)) {
return new SkillSearchError(
'GitHub Code Search rate limit exceeded. Authenticate with `gh auth login` or set GITHUB_TOKEN to raise the quota.',
Expand All @@ -184,6 +190,30 @@ function classifyApiError(status: number, body: unknown): SkillSearchError {
);
}

/**
* Resolve a GitHub API token for Code Search, mirroring the lookup order used
* by the `gh` CLI:
* 1. `GITHUB_TOKEN` env var
* 2. `GH_TOKEN` env var
* 3. `gh auth token` — reads the active credential from `gh`'s config/keyring
*
* Returns `undefined` when no credential is available (unauthenticated).
*/
export async function resolveGhToken(): Promise<string | undefined> {
const env = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (env) return env;
try {
const { execFile } = await import('node:child_process');
return await new Promise<string | undefined>((resolve) => {
execFile('gh', ['auth', 'token'], { timeout: 3000 }, (err, stdout) => {
resolve(err ? undefined : stdout.trim() || undefined);
});
});
} catch {
return undefined;
}
}

/**
* Render the namespace-qualified skill name (`<namespace>/<name>`) when a
* namespace is set, or just `<name>` otherwise. Used as the dedup key
Expand Down Expand Up @@ -328,21 +358,25 @@ async function runOneQuery(
* `repo + qualifiedName` keeping the first occurrence. That makes the
* path bucket win over the content bucket when both match the same skill.
*
* Network/auth come from `process.env.GITHUB_TOKEN` / `GH_TOKEN` when set;
* unauthenticated requests share the public 10 req/min Code Search rate limit.
* Auth is resolved by `resolveGhToken` (env vars → `gh auth token`) so
* credentials from `gh auth login` are used automatically.
*/
export async function searchSkills(
query: string,
options: SkillSearchOptions = {},
deps: { fetch?: typeof fetch; logger?: (msg: string) => void } = {},
deps: {
fetch?: typeof fetch;
logger?: (msg: string) => void;
tokenResolver?: () => Promise<string | undefined>;
} = {},
): Promise<SkillSearchResult> {
validateSkillSearchArgs(query, options);
const fetchFn = deps.fetch ?? fetch;
const logger = deps.logger ?? ((msg: string) => process.stderr.write(`${msg}\n`));

const page = options.page ?? 1;
const limit = options.limit ?? 30;
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
const token = await (deps.tokenResolver ?? resolveGhToken)();

const queries = buildSearchQueries(query, options.owner);
const settled = await Promise.allSettled(
Expand Down
86 changes: 86 additions & 0 deletions tests/unit/core/skill-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildSearchQueries,
couldBeOwner,
qualifiedName,
resolveGhToken,
searchSkills,
validateSkillSearchArgs,
} from '../../../src/core/skill-search.js';
Expand Down Expand Up @@ -527,3 +528,88 @@ describe('multi-query merge + dedup', () => {
expect(result.items.map((i) => i.sha)).toEqual(['p1', 'p2', 'p4']);
});
});

describe('token resolution', () => {
it('sends Authorization header from injected tokenResolver', async () => {
let capturedAuth: string | undefined;
const capturingFetch = (async (_url: string, init?: RequestInit) => {
capturedAuth = (init?.headers as Record<string, string>)?.Authorization;
return new Response(
JSON.stringify({ total_count: 0, incomplete_results: false, items: [] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof fetch;

await searchSkills('docs', {}, {
fetch: capturingFetch,
logger: silentLogger,
tokenResolver: async () => 'test-token-xyz',
});

expect(capturedAuth).toBe('token test-token-xyz');
});

it('omits Authorization header when tokenResolver returns undefined', async () => {
let capturedAuth: string | undefined;
const capturingFetch = (async (_url: string, init?: RequestInit) => {
capturedAuth = (init?.headers as Record<string, string>)?.Authorization;
return new Response(
JSON.stringify({ total_count: 0, incomplete_results: false, items: [] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}) as typeof fetch;

await searchSkills('docs', {}, {
fetch: capturingFetch,
logger: silentLogger,
tokenResolver: async () => undefined,
});

expect(capturedAuth).toBeUndefined();
});
});

describe('resolveGhToken', () => {
it('returns GITHUB_TOKEN env var when set', async () => {
const orig = process.env.GITHUB_TOKEN;
process.env.GITHUB_TOKEN = 'ghp_fromenv';
try {
expect(await resolveGhToken()).toBe('ghp_fromenv');
} finally {
if (orig === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = orig;
}
});

it('returns GH_TOKEN env var when GITHUB_TOKEN is absent', async () => {
const origG = process.env.GITHUB_TOKEN;
const origGH = process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
process.env.GH_TOKEN = 'ghp_fromgh';
try {
expect(await resolveGhToken()).toBe('ghp_fromgh');
} finally {
if (origG === undefined) delete process.env.GITHUB_TOKEN;
else process.env.GITHUB_TOKEN = origG;
if (origGH === undefined) delete process.env.GH_TOKEN;
else process.env.GH_TOKEN = origGH;
}
});
});

describe('searchSkills 401 error', () => {
it('maps 401 on the primary query to a SkillSearchError with kind "api"', async () => {
const fakeFetch = makeFakeFetch([
{ match: () => true, items: [], status: 401, message: 'Requires authentication' },
]);

try {
await searchSkills('docs', {}, { fetch: fakeFetch, logger: silentLogger, tokenResolver: async () => undefined });
throw new Error('should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(SkillSearchError);
expect((error as SkillSearchError).kind).toBe('api');
expect((error as SkillSearchError).message).toContain('gh auth login');
}
});
});