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

268 lines 12.7 kB
import { BaseCommand } from './BaseCommand.js'; import { BuildkiteRestClient } from '../services/BuildkiteRestClient.js'; import { logger } from '../services/logger.js'; import { parseBuildkiteReference } from '../utils/parseBuildkiteReference.js'; import { PlainStepLogsFormatter, JsonStepLogsFormatter, AlfredStepLogsFormatter } from '../formatters/step-logs/index.js'; import { SEMANTIC_COLORS, formatError } from '../ui/theme.js'; import { Progress } from '../ui/progress.js'; import * as fs from 'fs/promises'; // Terminal states where job is complete export const TERMINAL_JOB_STATES = ['passed', 'failed', 'canceled', 'timed_out', 'skipped', 'broken']; export function categorizePollError(error) { const message = error.message.toLowerCase(); if (message.includes('rate limit') || message.includes('429')) { return { category: 'rate_limited', message: error.message, retryable: true }; } if (message.includes('not found') || message.includes('404')) { return { category: 'not_found', message: error.message, retryable: false }; } if (message.includes('permission') || message.includes('403') || message.includes('401')) { return { category: 'permission_denied', message: error.message, retryable: false }; } if (message.includes('network') || message.includes('econnrefused') || message.includes('enotfound')) { return { category: 'network_error', message: error.message, retryable: true }; } return { category: 'unknown', message: error.message, retryable: true }; } export class ShowLogs extends BaseCommand { static requiresToken = true; async execute(options) { if (options.debug) { logger.debug('Starting ShowLogs command execution', options); } if (!options.buildRef) { logger.error('Build reference is required'); return 1; } const format = options.format || 'plain'; const spinner = Progress.spinner('Fetching step logs...', { format }); try { // Initialize token first this.token = await BaseCommand.getToken(options); // Parse the reference to extract org/pipeline/build and possibly stepId const ref = parseBuildkiteReference(options.buildRef); // Determine stepId - could come from argument, URL query param, or reference let stepId = options.stepId; if (!stepId && ref.type === 'build-with-step') { stepId = ref.stepId; } if (!stepId) { spinner.stop(); logger.error('Step ID is required. Provide it as an argument or include ?sid= in the URL'); return 1; } // Validate reference type if (ref.type !== 'build' && ref.type !== 'build-with-step') { spinner.stop(); logger.error('Invalid build reference. Expected format: org/pipeline/build or URL with build'); return 1; } // Use REST API to get build with jobs (step.id field needed for matching) const build = await this.restClient.getBuild(ref.org, ref.pipeline, ref.buildNumber); const jobs = build.jobs || []; if (!jobs || jobs.length === 0) { spinner.stop(); logger.error(`Build not found: ${ref.org}/${ref.pipeline}/${ref.buildNumber}`); return 1; } // Find job by step ID (sid from URL) - step.id is different from job.id if (options.debug) { logger.debug(`Found ${jobs.length} jobs in build`); logger.debug(`Looking for step ID: ${stepId}`); // Log first few jobs for debugging jobs.slice(0, 3).forEach((j) => { logger.debug(` Job: id=${j.id}, step.id=${j.step?.id}, name=${j.name}`); }); } const job = jobs.find((j) => j.step?.id === stepId); if (!job) { spinner.stop(); logger.error(`Step not found in build #${ref.buildNumber}: ${stepId}`); return 1; } // If follow mode and job not complete, enter polling loop if (options.follow && !this.isJobComplete(job)) { spinner.stop(); return await this.followLogs(ref, job, options); } // Use job.id (job UUID) for log fetching, not stepId const logData = await this.restClient.getJobLog(ref.org, ref.pipeline, ref.buildNumber, job.id); spinner.stop(); // Parse log content const logLines = logData.content.split('\n'); const totalLines = logLines.length; // Determine how many lines to display const linesToShow = options.full ? totalLines : (options.lines || 50); const startLine = Math.max(0, totalLines - linesToShow); const displayedLines = logLines.slice(startLine); // Save to file if requested if (options.save) { await fs.writeFile(options.save, logData.content); logger.console(SEMANTIC_COLORS.success(`✓ Log saved to ${options.save} (${this.formatSize(logData.size)}, ${totalLines} lines)`)); } // Prepare data for formatter using REST API fields from build object const data = { build: { org: ref.org, pipeline: ref.pipeline, number: ref.buildNumber, state: build.state || 'unknown', startedAt: build.started_at, finishedAt: build.finished_at, url: build.web_url, }, step: { id: job.id, label: job.name, state: job.state, exitStatus: job.exit_status, startedAt: job.started_at, finishedAt: job.finished_at, }, logs: { content: displayedLines.join('\n'), size: logData.size, totalLines, displayedLines: displayedLines.length, startLine, }, }; // Format and display (unless only saving) if (!options.save || options.format) { let formatter; if (format === 'alfred') { formatter = new AlfredStepLogsFormatter({ full: options.full, lines: options.lines }); } else if (format === 'json') { formatter = new JsonStepLogsFormatter({ full: options.full, lines: options.lines }); } else { formatter = new PlainStepLogsFormatter({ full: options.full, lines: options.lines }); } const output = formatter.format(data); logger.console(output); } return 0; } catch (error) { spinner.stop(); if (error instanceof Error) { if (error.message.includes('401') || error.message.includes('403')) { const errorOutput = formatError('Permission denied', { suggestions: [ 'Your API token needs \'read_build_logs\' scope to view logs', 'Update your token at: https://buildkite.com/user/api-access-tokens', ], }); logger.console(errorOutput); } else { const errorOutput = formatError(error.message, { suggestions: ['Check the reference format', 'Verify you have access to this resource'], }); logger.console(errorOutput); } } else { logger.error('Unknown error occurred'); } return 1; } } isJobComplete(job) { const state = (job.state || '').toLowerCase(); return TERMINAL_JOB_STATES.includes(state) || job.finished_at !== null; } async sleep(ms) { // Add ±10% jitter to prevent thundering herd const jitter = ms * 0.1 * (Math.random() * 2 - 1); return new Promise(resolve => setTimeout(resolve, ms + jitter)); } async followLogs(ref, initialJob, options) { const pollInterval = (options.pollInterval || 3) * 1000; let previousSize = 0; let currentInterval = pollInterval; let consecutiveErrors = 0; const maxErrors = 3; // Create a non-caching REST client for polling (we need fresh data each time) const pollClient = new BuildkiteRestClient(this.token, { caching: false, debug: options.debug }); // Set up signal handler for Ctrl+C let interrupted = false; const signalHandler = () => { interrupted = true; process.stderr.write('\n'); logger.console(SEMANTIC_COLORS.muted('Interrupted. Showing final state...')); }; process.on('SIGINT', signalHandler); try { // Show initial logs const initialLogs = await pollClient.getJobLog(ref.org, ref.pipeline, ref.buildNumber, initialJob.id); // Display header logger.console(SEMANTIC_COLORS.muted(`Following logs for: ${initialJob.name || initialJob.id}`)); logger.console(SEMANTIC_COLORS.muted('Press Ctrl+C to stop\n')); // Show initial content if (initialLogs.content) { const lines = initialLogs.content.split('\n'); const linesToShow = options.lines || 50; const startLine = Math.max(0, lines.length - linesToShow); logger.console(lines.slice(startLine).join('\n')); } previousSize = initialLogs.size; // Polling loop while (!interrupted) { await this.sleep(currentInterval); try { // Re-fetch build to check job state const build = await pollClient.getBuild(ref.org, ref.pipeline, ref.buildNumber); const job = build.jobs?.find((j) => j.id === initialJob.id); if (!job) { logger.error('Job no longer exists in build'); return 1; } // Fetch logs const logData = await pollClient.getJobLog(ref.org, ref.pipeline, ref.buildNumber, job.id); // Reset error counter on success consecutiveErrors = 0; currentInterval = pollInterval; // Display new content if any if (logData.size > previousSize) { const newContent = logData.content.slice(previousSize); if (newContent.trim()) { process.stdout.write(newContent); } previousSize = logData.size; } // Check if job is complete if (this.isJobComplete(job)) { logger.console(SEMANTIC_COLORS.muted(`\n\nJob ${job.state}: exit code ${job.exit_status ?? 'unknown'}`)); return job.exit_status === 0 ? 0 : 1; } } catch (error) { consecutiveErrors++; const pollError = categorizePollError(error); if (!pollError.retryable || consecutiveErrors >= maxErrors) { logger.error(`Failed to fetch logs: ${pollError.message}`); return 1; } // Exponential backoff currentInterval = Math.min(currentInterval * 2, 30000); logger.console(SEMANTIC_COLORS.warning(`Retrying in ${currentInterval / 1000}s...`)); } } // Interrupted - show final state return 0; } finally { process.removeListener('SIGINT', signalHandler); } } formatSize(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } } //# sourceMappingURL=ShowLogs.js.map