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
245 lines • 10.5 kB
JavaScript
import { BaseCommand } from './BaseCommand.js';
import { logger } from '../services/logger.js';
import { parseBuildRef } from '../utils/parseBuildRef.js';
import { FormatterFactory, FormatterType } from '../formatters/index.js';
import { Progress } from '../ui/progress.js';
import { BuildPoller } from '../services/BuildPoller.js';
import { BuildkiteRestClient } from '../services/BuildkiteRestClient.js';
import { getStateIcon, SEMANTIC_COLORS } from '../ui/theme.js';
export class ShowBuild extends BaseCommand {
static requiresToken = true;
async execute(options) {
// Handle watch mode
if (options.watch) {
return this.executeWatchMode(options);
}
if (options.debug) {
logger.debug('Starting ShowBuild command execution', options);
}
if (!options.buildArg) {
logger.error('Build reference is required');
return 1;
}
// Adjust options based on implications
const adjustedOptions = { ...options };
if (options.failed) {
adjustedOptions.jobs = true; // --failed implies --jobs
}
if (options.annotationsFull) {
adjustedOptions.annotations = true; // --annotations-full implies --annotations
}
if (options.allJobs) {
adjustedOptions.jobs = true; // --all-jobs implies --jobs
}
if (options.full) {
// --full shows everything
adjustedOptions.jobs = true;
adjustedOptions.annotations = true;
adjustedOptions.allJobs = true; // --full shows all jobs
}
// Initialize spinner early
const format = options.format || 'plain';
const spinner = Progress.spinner('Fetching build details…', { format });
try {
// Ensure the command is initialized
await this.ensureInitialized();
// Parse build reference
const buildRef = parseBuildRef(options.buildArg);
if (options.debug) {
logger.debug('Parsed build reference:', buildRef);
}
// Construct build slug for GraphQL
const buildSlug = `${buildRef.org}/${buildRef.pipeline}/${buildRef.number}`;
// Fetch build data based on what's needed
const buildData = await this.fetchBuildData(buildSlug, adjustedOptions);
spinner.stop();
// Get the appropriate formatter
const formatter = FormatterFactory.getFormatter(FormatterType.BUILD_DETAIL, options.format || 'plain');
// Format and output the results
const output = formatter.formatBuildDetail(buildData, adjustedOptions);
logger.console(output);
return 0;
}
catch (error) {
spinner.stop();
logger.error('Failed to fetch build:', error);
// Handle the error with the formatter
const formatter = FormatterFactory.getFormatter(FormatterType.BUILD_DETAIL, options.format || 'plain');
const errorOutput = formatter.formatBuildDetail(null, {
...adjustedOptions,
hasError: true,
errorMessage: error instanceof Error ? error.message : 'Unknown error occurred',
errorType: 'api',
});
logger.console(errorOutput);
return 1;
}
}
async fetchBuildData(buildSlug, options) {
// Determine what data we need to fetch based on options
const needsAllJobs = options.jobs || options.failed || options.full;
const needsAnnotations = options.annotations || options.annotationsFull || options.full;
if (options.debug) {
logger.debug('Fetching build data', {
buildSlug,
needsAllJobs,
needsAnnotations,
full: options.full
});
}
// Use the new pagination-aware method when we need all jobs
if (needsAllJobs) {
// Show progress when fetching many jobs (only in plain format)
const progressCallback = options.format === 'plain' || !options.format
? (fetched, total) => {
const totalStr = total ? `/${total}` : '';
process.stderr.write(`\rFetching jobs: ${fetched}${totalStr}...`);
}
: undefined;
const buildData = await this.client.getBuildSummaryWithAllJobs(buildSlug, {
fetchAllJobs: true,
onProgress: progressCallback
});
// Clear the progress line
if (progressCallback) {
process.stderr.write('\r\x1b[K'); // Clear the line
}
if (!buildData?.build) {
throw new Error(`Build not found: ${buildSlug}`);
}
// If we need full details (like command text), fetch that separately
if (options.full) {
// For now, getBuildFull still provides more detailed fields
// In the future, we could enhance the pagination query to include these
return await this.client.getBuildFull(buildSlug);
}
return buildData;
}
else {
// Just get the summary with first 100 jobs
const buildData = await this.client.getBuildSummary(buildSlug);
if (!buildData?.build) {
throw new Error(`Build not found: ${buildSlug}`);
}
return buildData;
}
}
displayWatchHeader(buildNumber, timeoutMinutes) {
logger.console(`Watching build #${buildNumber} (timeout: ${timeoutMinutes}m)`);
logger.console(SEMANTIC_COLORS.muted('Press Ctrl+C to stop\n'));
}
displayJobEvent(change) {
const time = change.timestamp.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const icon = getStateIcon(change.job.state);
const name = change.job.name || change.job.label || 'unknown';
const action = change.previousState === null ? 'started' : change.job.state;
logger.console(`${time} ${icon} ${name} ${action}`);
}
displayFinalSummary(build) {
const icon = getStateIcon(build.state);
const jobCount = build.jobs?.length || 0;
logger.console(`\n${icon} Build #${build.number} ${build.state} (${jobCount} jobs)`);
}
async executeWatchMode(options) {
if (!options.buildArg) {
logger.error('Build reference is required');
return 1;
}
await this.ensureInitialized();
const buildRef = parseBuildRef(options.buildArg);
const ref = {
org: buildRef.org,
pipeline: buildRef.pipeline,
buildNumber: buildRef.number,
};
const timeoutMinutes = parseInt(String(options.timeout || '30'), 10);
const pollIntervalSeconds = parseInt(String(options.pollInterval || '5'), 10);
// Create non-caching client for polling
const token = await BaseCommand.getToken(options);
if (!token) {
logger.error('No API token available');
return 1;
}
const pollClient = new BuildkiteRestClient(token, { caching: false, debug: options.debug });
const isJson = options.format === 'json';
// Display header
if (isJson) {
logger.console(JSON.stringify({
type: 'watching',
build: { number: buildRef.number, org: buildRef.org, pipeline: buildRef.pipeline },
timeout: `${timeoutMinutes}m`,
timestamp: new Date().toISOString(),
}));
}
else {
this.displayWatchHeader(buildRef.number, timeoutMinutes);
}
const poller = new BuildPoller(pollClient, {
onJobStateChange: (change) => {
if (isJson) {
logger.console(JSON.stringify({
type: 'job_changed',
job: { id: change.job.id, name: change.job.name, state: change.job.state },
previousState: change.previousState,
timestamp: change.timestamp.toISOString(),
}));
}
else {
this.displayJobEvent(change);
}
},
onBuildComplete: (build) => {
if (isJson) {
logger.console(JSON.stringify({
type: 'build_complete',
build: { number: build.number, state: build.state },
exitCode: build.state?.toLowerCase() === 'passed' ? 0 : 1,
timestamp: new Date().toISOString(),
}));
}
else {
this.displayFinalSummary(build);
}
},
onError: (err, willRetry) => {
if (isJson) {
logger.console(JSON.stringify({
type: 'error',
error: err,
willRetry,
timestamp: new Date().toISOString(),
}));
}
else if (willRetry) {
logger.console(SEMANTIC_COLORS.warning(`⚠ ${err.message}, retrying...`));
}
else {
logger.console(SEMANTIC_COLORS.error(`✗ ${err.message}`));
}
},
onTimeout: () => {
if (isJson) {
logger.console(JSON.stringify({
type: 'timeout',
timeout: `${timeoutMinutes}m`,
timestamp: new Date().toISOString(),
}));
}
else {
logger.console(SEMANTIC_COLORS.warning(`⏱ Timeout reached (${timeoutMinutes}m). Build still running.`));
}
},
}, {
initialInterval: pollIntervalSeconds * 1000,
timeout: timeoutMinutes * 60 * 1000,
});
const build = await poller.watch(ref);
return build.state?.toLowerCase() === 'passed' ? 0 : 1;
}
}
//# sourceMappingURL=ShowBuild.js.map