/**
* GitHub GraphQL client with authentication and error handling
* Following GitHub GraphQL API best practices: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql
*/
import { graphql } from '@octokit/graphql';
import { extractRateLimit, getWaitTime, sleep, shouldWaitForRateLimit } from '../utils/rateLimit.js';
export interface GraphQLResponse<T> {
data: T;
headers: Record<string, string>;
}
export class GitHubClient {
private client: typeof graphql;
private requestId: number = 0;
constructor(token: string) {
if (!token) {
throw new Error('GITHUB_TOKEN environment variable is required');
}
// Initialize GraphQL client with authentication
// See: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql#authenticating-with-graphql
this.client = graphql.defaults({
headers: {
authorization: `token ${token}`,
},
});
}
/**
* Executes a GraphQL query with rate limit handling
*
* Rate limits: https://docs.github.com/en/graphql/overview/resource-limitations#rate-limit
*
* @param query - GraphQL query string
* @param variables - Query variables
* @returns GraphQL response with data and headers
*/
async query<T = any>(
query: string,
variables: Record<string, any> = {}
): Promise<GraphQLResponse<T>> {
const currentRequestId = ++this.requestId;
try {
// Log request (without sensitive data)
if (process.env.DEBUG === 'true') {
console.log(`[Request ${currentRequestId}] Executing GraphQL query`, {
variables: this.sanitizeVariables(variables),
});
}
const response = (await this.client(query, variables)) as any;
// Extract rate limit info from response headers
// GitHub includes rate limit info in response headers
const rateLimit = extractRateLimit(response.headers || {});
if (rateLimit) {
if (process.env.DEBUG === 'true') {
console.log(`[Request ${currentRequestId}] Rate limit: ${rateLimit.remaining}/${rateLimit.limit}`);
}
// Wait if approaching rate limit to avoid hitting it
if (shouldWaitForRateLimit(rateLimit)) {
const waitTime = getWaitTime(rateLimit);
if (waitTime > 0) {
console.warn(`[Request ${currentRequestId}] Approaching rate limit, waiting ${waitTime}ms`);
await sleep(waitTime);
}
}
}
// GitHub GraphQL response structure: { data: {...}, headers: {...} }
// @octokit/graphql returns data directly or wrapped
const responseData = (response as any).data || response;
const responseHeaders = response.headers || {};
return {
data: responseData,
headers: responseHeaders,
};
} catch (error: any) {
// Handle authentication errors
// See: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql#authenticating-with-graphql
if (error.status === 401 || error.message?.includes('Bad credentials')) {
throw new Error(
'GitHub authentication failed. Check GITHUB_TOKEN environment variable and ensure it is valid.'
);
}
// Handle access forbidden errors (common for private org repos)
// See: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql#authenticating-with-graphql
if (error.status === 403 && !error.message?.includes('rate limit')) {
const errorMsg = error.message || 'Access forbidden';
if (errorMsg.includes('Could not resolve') || errorMsg.includes('Repository')) {
throw new Error(
`Access denied to repository. For private organization repositories, ensure:\n` +
` 1. Token has 'repo' scope (for private repos)\n` +
` 2. Token has 'read:org' scope (for organization repos)\n` +
` 3. Your account is a member of the organization\n` +
` 4. Organization allows third-party access (if applicable)`
);
}
throw new Error(`GitHub API access forbidden: ${errorMsg}`);
}
// Handle rate limit errors
// See: https://docs.github.com/en/graphql/overview/resource-limitations#rate-limit
if (error.status === 403 && error.message?.includes('rate limit')) {
const rateLimit = extractRateLimit(error.headers || {});
if (rateLimit) {
const waitTime = getWaitTime(rateLimit);
const resetTime = new Date(rateLimit.resetAt * 1000).toISOString();
throw new Error(
`GitHub API rate limit exceeded. Reset at ${resetTime}. Wait ${waitTime}ms before retrying.`
);
}
throw new Error('GitHub API rate limit exceeded');
}
// Log error with request ID
console.error(`[Request ${currentRequestId}] GraphQL query failed:`, error.message);
// Re-throw with more context if available
if (error.errors) {
const errorMessages = error.errors.map((e: any) => e.message).join('; ');
throw new Error(`GraphQL errors: ${errorMessages}`);
}
throw error;
}
}
/**
* Sanitizes variables for logging (removes sensitive data)
*/
private sanitizeVariables(variables: Record<string, any>): Record<string, any> {
const sanitized = { ...variables };
// Remove any potential sensitive fields
delete sanitized.token;
delete sanitized.password;
return sanitized;
}
}