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

391 lines 15 kB
import fetch from 'node-fetch'; import { mkdir as mkdirFs, writeFile as writeFileFs } from 'fs/promises'; import { dirname } from 'path'; import { CacheManager } from './CacheManager.js'; import { createHash } from 'crypto'; import { logger } from './logger.js'; import { getProgressIcon } from '../ui/theme.js'; /** * BuildkiteRestClient provides methods to interact with the Buildkite REST API */ export class BuildkiteRestClient { token; baseUrl = 'https://api.buildkite.com/v2'; cacheManager = null; debug = false; rateLimitInfo = null; /** * Create a new BuildkiteRestClient * @param token Your Buildkite API token * @param options Configuration options */ constructor(token, options) { this.token = token; this.debug = options?.debug || false; if (options?.baseUrl) { this.baseUrl = options.baseUrl; } // Initialize cache if caching is enabled if (options?.caching !== false) { this.cacheManager = new CacheManager(options?.cacheTTLs, this.debug); // Initialize cache and set token hash (async, but we don't wait) this.initCache().catch((err) => { logger.debug('Cache initialization failed, continuing without cache:', err); this.cacheManager = null; }); } } /** * Initialize cache asynchronously */ async initCache() { if (this.cacheManager) { await this.cacheManager.init(); await this.cacheManager.setTokenHash(this.token); } } /** * Generate a cache key for a REST endpoint */ generateCacheKey(endpoint, params) { const paramsString = params ? JSON.stringify(params) : ''; return `REST:${endpoint}:${this.hashString(paramsString)}`; } /** * Hash a string using SHA256 */ hashString(str) { return createHash('sha256').update(str).digest('hex'); } /** * Get cache type from endpoint */ getCacheTypeFromEndpoint(endpoint) { if (endpoint.includes('/builds')) { return 'builds'; } return 'default'; } /** * Make a GET request to the Buildkite REST API * @param endpoint The API endpoint * @param params Query parameters * @returns The API response */ async get(endpoint, params) { const url = new URL(`${this.baseUrl}${endpoint}`); // Add query parameters if (params) { Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { url.searchParams.append(key, value); } }); } // Generate cache key const cacheKey = this.generateCacheKey(endpoint, params); const cacheType = this.getCacheTypeFromEndpoint(endpoint); // Check cache first if enabled if (this.cacheManager) { const cached = await this.cacheManager.get(cacheKey, cacheType); if (cached) { if (this.debug) { logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served from cache: REST ${endpoint}`); } return cached; } } const startTime = process.hrtime.bigint(); if (this.debug) { logger.debug(`${getProgressIcon('STARTING')} Starting REST API request: GET ${endpoint}`); logger.debug(`${getProgressIcon('STARTING')} Request URL: ${url.toString()}`); if (params) { logger.debug(`${getProgressIcon('STARTING')} Request params: ${JSON.stringify(params)}`); } } try { const response = await fetch(url.toString(), { headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json', }, }); // Update rate limit info from headers this.rateLimitInfo = { remaining: parseInt(response.headers.get('RateLimit-Remaining') || '0'), limit: parseInt(response.headers.get('RateLimit-Limit') || '0'), reset: parseInt(response.headers.get('RateLimit-Reset') || '0'), }; if (this.debug) { logger.debug('Rate limit info:', this.rateLimitInfo); } if (!response.ok) { const errorText = await response.text(); let errorMessage = `API request failed with status ${response.status}: ${errorText}`; // Try to parse the error as JSON for more details try { const errorJson = JSON.parse(errorText); if (errorJson.message) { errorMessage = `API request failed: ${errorJson.message}`; } if (errorJson.errors && Array.isArray(errorJson.errors)) { errorMessage += `\nErrors: ${errorJson.errors.map((e) => e.message).join(', ')}`; } } catch (e) { // If parsing fails, use the original error text } // Check if this is an authentication error const isAuthError = this.isAuthenticationError(response.status, errorMessage); if (isAuthError && this.debug) { logger.debug('Authentication error detected, not caching result'); } throw new Error(errorMessage); } const data = await response.json(); // Cache the response if caching is enabled if (this.cacheManager) { await this.cacheManager.set(cacheKey, data, cacheType); } const endTime = process.hrtime.bigint(); const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds if (this.debug) { logger.debug(`${getProgressIcon('SUCCESS_LOG')} REST API request completed: GET ${endpoint} (${duration.toFixed(2)}ms)`); } return data; } catch (error) { if (this.debug) { logger.error('Error in get request:', error); } throw error; } } /** * Check if an error is an authentication error */ isAuthenticationError(status, message) { // Check for HTTP status codes that indicate auth issues if (status === 401 || status === 403) { return true; } // Check error message for auth-related keywords const lowerMessage = message.toLowerCase(); return lowerMessage.includes('unauthorized') || lowerMessage.includes('authentication') || lowerMessage.includes('permission') || lowerMessage.includes('invalid token'); } /** * Get the current rate limit information * @returns Current rate limit information or null if not available */ getRateLimitInfo() { return this.rateLimitInfo; } /** * Fetch the current token's UUID and scope list. * Uses /v2/access-token, which any valid Buildkite API token can call. */ async getAccessToken() { return this.get('/access-token'); } /** * Get builds from an organization filtered by specific parameters * @param org Organization slug * @param params Query parameters * @returns List of builds */ async getBuilds(org, params) { const endpoint = `/organizations/${org}/builds`; const startTime = process.hrtime.bigint(); if (this.debug) { logger.debug(`${getProgressIcon('STARTING')} Fetching builds for organization: ${org}`); } const builds = await this.get(endpoint, params); const endTime = process.hrtime.bigint(); const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds if (this.debug) { logger.debug(`${getProgressIcon('SUCCESS_LOG')} Retrieved ${builds.length} builds for ${org} (${duration.toFixed(2)}ms)`); } return builds; } /** * Get builds for a specific pipeline * @param org Organization slug * @param pipeline Pipeline slug * @param params Query parameters * @returns List of builds for the pipeline */ async getPipelineBuilds(org, pipeline, params) { const endpoint = `/organizations/${org}/pipelines/${pipeline}/builds`; const startTime = process.hrtime.bigint(); if (this.debug) { logger.debug(`${getProgressIcon('STARTING')} Fetching builds for pipeline: ${org}/${pipeline}`); } const builds = await this.get(endpoint, params); const endTime = process.hrtime.bigint(); const duration = Number(endTime - startTime) / 1000000; // Convert to milliseconds if (this.debug) { logger.debug(`${getProgressIcon('SUCCESS_LOG')} Retrieved ${builds.length} builds for ${org}/${pipeline} (${duration.toFixed(2)}ms)`); } return builds; } async hasBuildAccess(org) { try { await this.getBuilds(org, { per_page: '1' }); return true; } catch (error) { return false; } } /** * Check if the current user has access to an organization * @param org Organization slug * @returns True if the user has access, false otherwise */ async hasOrganizationAccess(org) { try { const endpoint = `/organizations/${org}`; await this.get(endpoint); return true; } catch (error) { if (this.debug) { logger.debug(`User does not have access to organization: ${org}`); } return false; } } /** * Get logs for a specific job * @param org Organization slug * @param pipeline Pipeline slug * @param buildNumber Build number * @param jobId Job ID (UUID) * @returns Job log data */ async getJobLog(org, pipeline, buildNumber, jobId) { const endpoint = `/organizations/${org}/pipelines/${pipeline}/builds/${buildNumber}/jobs/${jobId}/log`; const startTime = process.hrtime.bigint(); if (this.debug) { logger.debug(`${getProgressIcon('STARTING')} Fetching logs for job: ${jobId}`); } // Note: Use default cache type for log data const cacheKey = this.generateCacheKey(endpoint); // Check cache first if (this.cacheManager) { const cached = await this.cacheManager.get(cacheKey, 'default'); if (cached) { if (this.debug) { logger.debug(`${getProgressIcon('SUCCESS_LOG')} Served logs from cache for job: ${jobId}`); } return cached; } } const log = await this.get(endpoint); const endTime = process.hrtime.bigint(); const duration = Number(endTime - startTime) / 1000000; if (this.debug) { logger.debug(`${getProgressIcon('SUCCESS_LOG')} Retrieved logs for job ${jobId} (${duration.toFixed(2)}ms)`); } return log; } /** * Get annotations for a build * @param org Organization slug * @param pipeline Pipeline slug * @param buildNumber Build number * @returns Array of annotations */ async getBuildAnnotations(org, pipeline, buildNumber) { const endpoint = `/organizations/${org}/pipelines/${pipeline}/builds/${buildNumber}/annotations`; if (this.debug) { logger.debug(`Fetching annotations for ${org}/${pipeline}/${buildNumber}`); } return this.get(endpoint); } /** * Get jobs for a specific build */ async getBuildJobs(org, pipeline, buildNumber) { const build = await this.getBuild(org, pipeline, buildNumber); return build.jobs || []; } /** * Get a specific build with all its data including jobs * @param org Organization slug * @param pipeline Pipeline slug * @param buildNumber Build number * @returns Full build object including jobs array */ async getBuild(org, pipeline, buildNumber) { const endpoint = `/organizations/${org}/pipelines/${pipeline}/builds/${buildNumber}`; return this.get(endpoint); } /** * List all artifacts for a build (build-scoped, includes artifacts from all jobs) */ async listBuildArtifacts(org, pipeline, buildNumber) { const endpoint = `/organizations/${org}/pipelines/${pipeline}/builds/${buildNumber}/artifacts`; if (this.debug) { logger.debug(`Fetching artifacts for ${org}/${pipeline}/${buildNumber}`); } return this.get(endpoint); } /** * Download an artifact to a local file path using the two-step presigned URL flow. * Calls artifact.download_url to get a presigned URL (via 302 + JSON body), * then fetches that URL and writes the binary to disk. */ async downloadArtifact(artifact, destPath) { const apiRes = await fetch(artifact.download_url, { headers: { 'Authorization': `Bearer ${this.token}` }, redirect: 'manual', }); if (!apiRes.ok && apiRes.status !== 302) { const text = await apiRes.text().catch(() => ''); throw new Error(`Failed to get artifact download URL (${apiRes.status})${text ? ': ' + text : ''}`); } let presignedUrl = null; try { const json = await apiRes.json(); presignedUrl = json.url ?? null; } catch { // JSON body absent or unparseable — fall through to Location header } if (!presignedUrl) { presignedUrl = apiRes.headers.get('location'); } if (!presignedUrl) { throw new Error(`Could not determine download URL for artifact ${artifact.id}`); } const fileRes = await fetch(presignedUrl); if (!fileRes.ok) { throw new Error(`Artifact download failed (${fileRes.status}): presigned URL request failed`); } const buffer = await fileRes.arrayBuffer(); await mkdirFs(dirname(destPath), { recursive: true }); await writeFileFs(destPath, Buffer.from(buffer)); return { path: destPath, size: buffer.byteLength }; } /** * 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); } } } //# sourceMappingURL=BuildkiteRestClient.js.map