UNPKG

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
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