bktide
Version:
Command-line interface for Buildkite CI/CD workflows with rich shell completions (Fish, Bash, Zsh) and Alfred workflow integration for macOS power users
657 lines • 28 kB
JavaScript
import { GraphQLClient } from 'graphql-request';
import { CacheManager } from './CacheManager.js';
import { getProgressIcon } from '../ui/theme.js';
// Import the queries - we'll use them for both string queries and typed SDK
import { GET_VIEWER, GET_ORGANIZATIONS, GET_PIPELINES, GET_BUILDS, GET_VIEWER_BUILDS, GET_BUILD_ANNOTATIONS, GET_BUILD_SUMMARY, GET_BUILD_FULL, GET_BUILD_JOBS_PAGE } from '../graphql/queries.js';
import { logger } from './logger.js';
/**
* BuildkiteClient provides methods to interact with the Buildkite GraphQL API
*/
export class BuildkiteClient {
client;
token;
baseUrl = 'https://graphql.buildkite.com/v1';
cacheManager = null;
debug = false;
rateLimitInfo = null;
/**
* Create a new BuildkiteClient
* @param token Your Buildkite API token
* @param options Configuration options
*/
constructor(token, options, debug) {
this.token = token;
this.debug = debug || options?.debug || false;
if (this.debug) {
logger.debug('Initializing BuildkiteClient with options:', {
baseUrl: options?.baseUrl || this.baseUrl,
caching: options?.caching !== false,
debug: this.debug,
tokenLength: token ? token.length : 0
});
}
if (options?.baseUrl) {
this.baseUrl = options.baseUrl;
}
this.client = new GraphQLClient(this.baseUrl, {
headers: {
Authorization: `Bearer ${this.token}`,
},
});
// Initialize cache if caching is enabled
if (options?.caching !== false) {
if (this.debug) {
logger.debug('BuildkiteClient constructor - creating CacheManager');
}
this.cacheManager = new CacheManager(options?.cacheTTLs, this.debug);
// Initialize cache and set token hash (async, but we don't wait)
this.initCache();
}
else {
if (this.debug) {
logger.debug('BuildkiteClient constructor - caching disabled');
}
}
}
/**
* Initialize cache asynchronously
*/
async initCache() {
if (this.cacheManager) {
await this.cacheManager.init();
await this.cacheManager.setTokenHash(this.token);
}
}
/**
* Execute a GraphQL query
* @param query The GraphQL query
* @param variables Variables for the query
* @returns The query response
*/
async query(query, variables) {
try {
const startTime = process.hrtime.bigint();
const operationName = query.match(/query\s+(\w+)?/)?.[1] || 'UnnamedQuery';
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL query: ${operationName}`);
}
// Check if result is in cache
if (this.cacheManager) {
if (this.debug) {
logger.debug('query() - cacheManager exists, checking cache');
}
const cachedResult = await this.cacheManager.get(query, variables);
if (cachedResult) {
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served from cache: ${operationName}`);
}
return cachedResult;
}
}
const response = await this.client.request(query, variables);
// Store result in cache if caching is enabled
if (this.cacheManager) {
await this.cacheManager.set(query, response, variables);
}
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} GraphQL query completed: ${operationName} (${duration.toFixed(2)}ms)`);
}
return response;
}
catch (error) {
const isAuthError = this.isAuthenticationError(error);
if (isAuthError && this.debug) {
logger.debug('Authentication error detected, not caching result');
}
if (this.debug) {
logger.error(error, 'Error in GraphQL query');
// Log raw error information
logger.debug('Raw error object:', {
error,
type: typeof error,
constructor: error?.constructor?.name,
keys: error && typeof error === 'object' ? Object.keys(error) : undefined
});
// Log more detailed error information
if (error instanceof Error && 'response' in error) {
const response = error.response;
logger.debug('GraphQL error details:', {
status: response?.status,
statusText: response?.statusText,
errors: response?.errors,
data: response?.data,
headers: response?.headers ? Object.fromEntries(response.headers.entries()) : undefined
});
}
}
throw error;
}
}
/**
* Check if an error is an authentication error
*/
isAuthenticationError(error) {
// Check for common authentication error patterns
if (error.response?.errors) {
const errors = error.response.errors;
return errors.some((err) => err.message?.includes('unauthorized') ||
err.message?.includes('authentication') ||
err.message?.includes('permission') ||
err.message?.includes('invalid token'));
}
// Check for HTTP status codes that indicate auth issues
if (error.response?.status) {
const status = error.response.status;
return status === 401 || status === 403;
}
// Check error message directly
if (error.message) {
return error.message.includes('unauthorized') ||
error.message.includes('authentication') ||
error.message.includes('permission') ||
error.message.includes('invalid token');
}
return false;
}
/**
* Execute a GraphQL mutation
* @param mutation The GraphQL mutation
* @param variables Variables for the mutation
* @returns The mutation response
*/
async mutate(mutation, variables) {
try {
const startTime = process.hrtime.bigint();
const operationName = mutation.match(/mutation\s+(\w+)?/)?.[1] || 'UnnamedMutation';
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL mutation: ${operationName}`);
}
const result = await this.client.request(mutation, variables);
// Invalidate relevant caches after mutations
if (this.cacheManager) {
// Determine what cache types to invalidate based on mutation name/content
if (mutation.includes('Pipeline')) {
await this.cacheManager.invalidateType('pipelines');
}
else if (mutation.includes('Build')) {
await this.cacheManager.invalidateType('builds');
}
}
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
logger.debug(`${getProgressIcon('SUCCESS_LOG')} GraphQL mutation completed: ${operationName} (${duration.toFixed(2)}ms)`);
return result;
}
catch (error) {
logger.error('GraphQL mutation error:', error);
throw error;
}
}
/**
* Get the organization slugs for the current viewer
* @returns An array of organization slugs the current user belongs to
*/
async getViewerOrganizationSlugs() {
try {
const startTime = process.hrtime.bigint();
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL query: getViewerOrganizationSlugs`);
}
// Get the organizations using our query
if (this.debug) {
logger.debug('About to call this.query with GET_ORGANIZATIONS');
}
const data = await this.query(GET_ORGANIZATIONS.toString());
if (this.debug) {
logger.debug('Successfully got data from this.query');
}
if (this.debug) {
logger.debug('Raw GraphQL response for organizations:', {
hasData: !!data,
hasViewer: !!data?.viewer,
hasOrganizations: !!data?.viewer?.organizations,
hasEdges: !!data?.viewer?.organizations?.edges,
edgesLength: data?.viewer?.organizations?.edges?.length || 0
});
}
// Use our helper method to process the response
const organizations = this.processOrganizationsResponse(data);
if (this.debug) {
logger.debug('Processed organizations:', {
count: organizations.length,
organizations: organizations.map(org => ({ id: org.id, name: org.name, slug: org.slug }))
});
}
if (organizations.length === 0) {
if (this.debug) {
logger.debug('No organizations found in response', { data });
}
return [];
}
// Map to just the slugs
const slugs = organizations.map(org => org.slug);
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} Found ${slugs.length} organizations (${duration.toFixed(2)}ms)`);
}
return slugs;
}
catch (error) {
if (this.debug) {
logger.debug('GraphQL query failed', {
error: error instanceof Error ? error.message : String(error),
details: error instanceof Error ? error : undefined
});
// Log more detailed error information
if (error instanceof Error && 'response' in error) {
const response = error.response;
logger.debug('GraphQL error response:', {
status: response?.status,
statusText: response?.statusText,
errors: response?.errors,
data: response?.data
});
}
// Log detailed error information
logger.debug('Error in getViewerOrganizationSlugs:', {
errorMessage: error instanceof Error ? error.message : String(error),
errorType: error?.constructor?.name,
hasResponse: error instanceof Error && 'response' in error,
response: error instanceof Error && 'response' in error ? error.response : undefined
});
}
throw new Error('Failed to determine your organizations', { cause: error });
}
}
/**
* Clear all cache entries
*/
async clearCache() {
if (this.cacheManager) {
await this.cacheManager.clear();
}
}
/**
* Invalidate a specific cache type
*/
async invalidateCache(type) {
if (this.cacheManager) {
await this.cacheManager.invalidateType(type);
}
}
/**
* Get the current viewer information with type safety
* @returns The viewer data
*/
async getViewer() {
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL query: GetViewer`);
}
// Check if result is in cache
if (this.cacheManager) {
const cachedResult = await this.cacheManager.get(GET_VIEWER.toString(), {});
if (cachedResult) {
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served from cache: GetViewer`);
}
return cachedResult;
}
}
const startTime = process.hrtime.bigint();
const result = await this.client.request(GET_VIEWER.toString());
// Store result in cache if caching is enabled
if (this.cacheManager) {
await this.cacheManager.set(GET_VIEWER.toString(), result, {});
}
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} GraphQL query completed: GetViewer (${duration.toFixed(2)}ms)`);
}
return result;
}
/**
* Get organizations for the current viewer
* @returns An array of organization objects with id, name, and slug
*/
async getOrganizations() {
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL query: GetOrganizations`);
}
// Check if result is in cache
if (this.cacheManager) {
const cachedResult = await this.cacheManager.get(GET_ORGANIZATIONS.toString(), {});
if (cachedResult) {
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served from cache: GetOrganizations`);
}
return this.processOrganizationsResponse(cachedResult);
}
}
const startTime = process.hrtime.bigint();
const result = await this.client.request(GET_ORGANIZATIONS.toString());
// Store result in cache if caching is enabled
if (this.cacheManager) {
await this.cacheManager.set(GET_ORGANIZATIONS.toString(), result, {});
}
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} GraphQL query completed: GetOrganizations (${duration.toFixed(2)}ms)`);
}
return this.processOrganizationsResponse(result);
}
/**
* Process the raw GraphQL organizations response into a clean array
* @param data The raw GraphQL response
* @returns A processed array of organization objects
* @private
*/
processOrganizationsResponse(data) {
if (!data?.viewer?.organizations?.edges) {
return [];
}
const edges = data.viewer.organizations.edges;
// Filter out null edges and map to non-null nodes
const organizations = edges
.filter((edge) => edge !== null)
.map(edge => edge.node)
.filter((node) => node !== null);
return organizations;
}
/**
* Get pipelines for an organization with type safety
* @param organizationSlug The organization slug
* @param first Number of pipelines to retrieve
* @param after Cursor for pagination
* @returns The pipelines data
*/
async getPipelines(organizationSlug, first, after) {
const variables = {
organizationSlug,
first,
after,
};
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL query: GetPipelines for ${organizationSlug}`);
}
// Check if result is in cache
if (this.cacheManager) {
const cachedResult = await this.cacheManager.get(GET_PIPELINES.toString(), variables);
if (cachedResult) {
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served from cache: GetPipelines`);
}
return cachedResult;
}
}
const startTime = process.hrtime.bigint();
const result = await this.client.request(GET_PIPELINES.toString(), variables);
// Store result in cache if caching is enabled
if (this.cacheManager) {
await this.cacheManager.set(GET_PIPELINES.toString(), result, variables);
}
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} GraphQL query completed: GetPipelines (${duration.toFixed(2)}ms)`);
}
return result;
}
/**
* Get builds for a pipeline with type safety
* @param pipelineSlug The pipeline slug
* @param organizationSlug The organization slug
* @param first Number of builds to retrieve
* @returns The builds data
*/
async getBuilds(pipelineSlug, organizationSlug, first) {
const variables = {
pipelineSlug,
organizationSlug,
first,
};
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL query: GetBuilds for ${pipelineSlug} in ${organizationSlug}`);
}
// Check if result is in cache
if (this.cacheManager) {
const cachedResult = await this.cacheManager.get(GET_BUILDS.toString(), variables);
if (cachedResult) {
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served from cache: GetBuilds`);
}
return cachedResult;
}
}
const startTime = process.hrtime.bigint();
const result = await this.client.request(GET_BUILDS.toString(), variables);
// Store result in cache if caching is enabled
if (this.cacheManager) {
await this.cacheManager.set(GET_BUILDS.toString(), result, variables);
}
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} GraphQL query completed: GetBuilds (${duration.toFixed(2)}ms)`);
}
return result;
}
/**
* Get builds for the current viewer with type safety
* @param first Number of builds to retrieve
* @returns The viewer builds data
*/
async getViewerBuilds(first) {
const variables = {
first,
};
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL query: GetViewerBuilds`);
}
// Check if result is in cache
if (this.cacheManager) {
const cachedResult = await this.cacheManager.get(GET_VIEWER_BUILDS.toString(), variables);
if (cachedResult) {
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served from cache: GetViewerBuilds`);
}
return cachedResult;
}
}
const startTime = process.hrtime.bigint();
const result = await this.client.request(GET_VIEWER_BUILDS.toString(), variables);
// Store result in cache if caching is enabled
if (this.cacheManager) {
await this.cacheManager.set(GET_VIEWER_BUILDS.toString(), result, variables);
}
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} GraphQL query completed: GetViewerBuilds (${duration.toFixed(2)}ms)`);
}
return result;
}
/**
* Get the current rate limit information
* @returns Current rate limit information or null if not available
*/
getRateLimitInfo() {
return this.rateLimitInfo;
}
/**
* Get annotations for a build with type safety
* @param buildSlug The build slug (e.g., "org/pipeline/number")
* @returns The build annotations data
*/
async getBuildAnnotations(buildSlug) {
const variables = {
buildSlug,
};
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL query: GetBuildAnnotations for ${buildSlug}`);
}
// Check if result is in cache
if (this.cacheManager) {
const cachedResult = await this.cacheManager.get(GET_BUILD_ANNOTATIONS.toString(), variables);
if (cachedResult) {
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served from cache: GetBuildAnnotations`);
}
return cachedResult;
}
}
const startTime = process.hrtime.bigint();
const result = await this.client.request(GET_BUILD_ANNOTATIONS.toString(), variables);
// Store result in cache if caching is enabled
if (this.cacheManager) {
await this.cacheManager.set(GET_BUILD_ANNOTATIONS.toString(), result, variables);
}
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} GraphQL query completed: GetBuildAnnotations (${duration.toFixed(2)}ms)`);
}
return result;
}
async getBuildSummary(buildSlug) {
const variables = {
slug: buildSlug,
};
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL query: GetBuildSummary for ${buildSlug}`);
}
// Check if result is in cache
if (this.cacheManager) {
const cachedResult = await this.cacheManager.get('GET_BUILD_SUMMARY', variables);
if (cachedResult) {
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served from cache: GetBuildSummary`);
}
return cachedResult;
}
}
const startTime = process.hrtime.bigint();
const result = await this.client.request(GET_BUILD_SUMMARY.toString(), variables);
// Store result in cache if caching is enabled
if (this.cacheManager) {
await this.cacheManager.set('GET_BUILD_SUMMARY', result, variables);
}
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} GraphQL query completed: GetBuildSummary (${duration.toFixed(2)}ms)`);
}
return result;
}
async getBuildFull(buildSlug) {
const variables = {
slug: buildSlug,
};
if (this.debug) {
logger.debug(`${getProgressIcon('STARTING')} Starting GraphQL query: GetBuildFull for ${buildSlug}`);
}
// Check if result is in cache
if (this.cacheManager) {
const cachedResult = await this.cacheManager.get('GET_BUILD_FULL', variables);
if (cachedResult) {
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served from cache: GetBuildFull`);
}
return cachedResult;
}
}
const startTime = process.hrtime.bigint();
const result = await this.client.request(GET_BUILD_FULL.toString(), variables);
// Store result in cache if caching is enabled
if (this.cacheManager) {
await this.cacheManager.set('GET_BUILD_FULL', result, variables);
}
const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds
if (this.debug) {
logger.debug(`${getProgressIcon('SUCCESS_LOG')} GraphQL query completed: GetBuildFull (${duration.toFixed(2)}ms)`);
}
return result;
}
/**
* Fetch remaining job pages for a build
* Uses the initial data from GET_BUILD_SUMMARY and only fetches additional pages
* @param buildSlug The build slug
* @param initialJobs Jobs already fetched from GET_BUILD_SUMMARY
* @param initialPageInfo PageInfo from GET_BUILD_SUMMARY
* @param options Pagination options
* @returns Complete job list including initial and additional jobs
*/
async fetchRemainingJobs(buildSlug, initialJobs, initialPageInfo, options) {
// Start with the jobs we already have
const allJobs = [...initialJobs];
let cursor = initialPageInfo.endCursor;
let hasMore = initialPageInfo.hasNextPage;
if (!hasMore) {
// No additional pages needed
return { jobs: allJobs, totalCount: allJobs.length };
}
if (this.debug) {
logger.debug(`Build has additional job pages, starting pagination from cursor: ${cursor?.substring(0, 20)}...`);
}
while (hasMore && cursor) {
const variables = {
slug: buildSlug,
first: 100,
after: cursor
};
if (this.debug) {
logger.debug(`Fetching additional jobs page: cursor=${cursor?.substring(0, 20)}...`);
}
// Use the lightweight page query
const result = await this.query(GET_BUILD_JOBS_PAGE.toString(), variables);
const jobEdges = result.build?.jobs?.edges || [];
allJobs.push(...jobEdges);
// Update pagination info
cursor = result.build?.jobs?.pageInfo?.endCursor || null;
hasMore = result.build?.jobs?.pageInfo?.hasNextPage || false;
// Report progress if callback provided
if (options?.onProgress) {
const totalCount = result.build?.jobs?.count || allJobs.length;
options.onProgress(allJobs.length, totalCount);
}
if (this.debug) {
logger.debug(`Fetched ${jobEdges.length} additional jobs, total: ${allJobs.length}`);
}
}
return { jobs: allJobs, totalCount: allJobs.length };
}
/**
* Enhanced getBuildSummary that supports complete job fetching
*/
async getBuildSummaryWithAllJobs(buildSlug, options) {
// Fetch initial build data with first 100 jobs
const buildData = await this.getBuildSummary(buildSlug);
// Check if we need to fetch additional job pages
const jobsData = buildData.build?.jobs;
const pageInfo = jobsData?.pageInfo;
if (!options?.fetchAllJobs || !pageInfo?.hasNextPage) {
// Either we don't want all jobs, or there are no more pages
return buildData;
}
// Fetch remaining job pages
const { jobs: allJobs, totalCount } = await this.fetchRemainingJobs(buildSlug, jobsData.edges, pageInfo, { onProgress: options.onProgress });
// Merge the complete job list into the build data
return {
...buildData,
build: {
...buildData.build,
jobs: {
edges: allJobs,
pageInfo: { hasNextPage: false, endCursor: null },
count: totalCount
}
}
};
}
}
//# sourceMappingURL=BuildkiteClient.js.map