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