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

1,174 lines (1,173 loc) 50.7 kB
import { BaseBuildDetailFormatter } from './Formatter.js'; import { formatDistanceToNow } from 'date-fns'; import { htmlToText } from 'html-to-text'; import { formatEmptyState, formatError, SEMANTIC_COLORS, formatBuildStatus, formatTips, TipStyle, getStateIcon, getAnnotationIcon, getProgressIcon, BUILD_STATUS_THEME } from '../../ui/theme.js'; import { useAscii } from '../../ui/symbols.js'; import { termWidth } from '../../ui/width.js'; // Standard emoji mappings only // Only map universally recognized emoji codes, not Buildkite-specific ones const STANDARD_EMOJI = { // Faces & emotions ':smile:': '😊', ':grin:': '😁', ':joy:': '😂', ':laughing:': '😆', ':blush:': '😊', ':heart_eyes:': '😍', ':sob:': '😭', ':cry:': '😢', ':angry:': '😠', ':rage:': '😡', ':thinking:': '🤔', ':confused:': '😕', ':neutral_face:': '😐', // Hands & gestures ':thumbsup:': '👍', ':thumbsdown:': '👎', ':clap:': '👏', ':wave:': '👋', ':raised_hand:': '✋', ':ok_hand:': '👌', ':pray:': '🙏', ':muscle:': '💪', ':point_left:': '👈', ':point_right:': '👉', ':point_up:': '👆', ':point_down:': '👇', // Objects & symbols ':heart:': '❤️', ':broken_heart:': '💔', ':star:': '⭐', ':sparkles:': '✨', ':boom:': '💥', ':fire:': '🔥', ':zap:': '⚡', ':rocket:': '🚀', ':sun:': '☀️', ':moon:': '🌙', ':cloud:': '☁️', ':umbrella:': '☔', ':snowflake:': '❄️', // Status symbols ':white_check_mark:': '✅', ':x:': '❌', ':warning:': '⚠️', ':exclamation:': '❗', ':question:': '❓', ':heavy_plus_sign:': '➕', ':heavy_minus_sign:': '➖', ':heavy_check_mark:': '✔️', // Common tools/tech (universally recognized) ':computer:': '💻', ':iphone:': '📱', ':email:': '📧', ':package:': '📦', ':lock:': '🔒', ':key:': '🔑', ':mag:': '🔍', ':bulb:': '💡', ':books:': '📚', ':memo:': '📝', ':pencil:': '✏️', ':art:': '🎨', ':camera:': '📷', ':movie_camera:': '🎥', ':musical_note:': '🎵', ':bell:': '🔔', ':link:': '🔗', ':paperclip:': '📎', ':hourglass:': '⏳', ':alarm_clock:': '⏰', ':stopwatch:': '⏱️', ':timer_clock:': '⏲️', ':calendar:': '📅', ':date:': '📅', }; export class PlainTextFormatter extends BaseBuildDetailFormatter { name = 'plain-text'; parseEmoji(text) { if (!text) return text; // Only replace standard emoji codes, leave Buildkite-specific ones as-is return text.replace(/:[\w_]+:/g, (match) => { return STANDARD_EMOJI[match] || match; }); } formatBuildDetail(buildData, options) { // Handle error cases first if (options?.hasError || !buildData) { return this.formatErrorState(options); } const build = buildData.build; // Choose display mode based on options if (options?.summary) { return this.formatSummaryLine(build); } if (options?.full) { return this.formatFullDetails(build, options); } // Default: contextual display based on state switch (build.state) { case 'FAILED': return this.formatFailedBuild(build, options); case 'RUNNING': return this.formatRunningBuild(build, options); case 'BLOCKED': return this.formatBlockedBuild(build, options); case 'PASSED': return this.formatPassedBuild(build, options); case 'CANCELED': return this.formatCanceledBuild(build, options); default: return this.formatDefaultBuild(build, options); } } formatErrorState(options) { if (options?.errorType === 'not_found') { return formatEmptyState('Build not found', ['Check the build reference format', 'Verify the build exists']); } return formatError(options?.errorMessage || 'Unknown error'); } formatSummaryLine(build) { const statusIcon = this.getStatusIcon(build.state); const coloredIcon = this.colorizeStatusIcon(statusIcon, build.state); const duration = this.formatDuration(build); const age = this.formatAge(build.createdAt); const stateFormatted = formatBuildStatus(build.state, { useSymbol: false }); const branch = SEMANTIC_COLORS.identifier(build.branch); return `${coloredIcon} ${SEMANTIC_COLORS.label(`#${build.number}`)} ${stateFormatted} • ${duration} • ${branch} • ${age}`; } formatPassedBuild(build, options) { const lines = []; // Header line lines.push(this.formatHeader(build)); lines.push(this.formatCommitInfo(build)); lines.push(''); // Blank line after commit info // Show annotations summary if present if (build.annotations?.edges?.length > 0) { lines.push(this.formatAnnotationSummary(build.annotations.edges)); } // Jobs summary if (build.jobs?.edges?.length > 0) { if (build.annotations?.edges?.length > 0) { lines.push(''); // Add space between annotations and steps } lines.push(this.formatJobSummary(build.jobs, build.state)); if (!options?.annotations && options?.tips !== false) { lines.push(''); const tips = formatTips(['Use --annotations to view annotation details'], TipStyle.GROUPED); lines.push(tips); } } // Show annotations detail if requested if (options?.annotations) { lines.push(''); lines.push(this.formatAnnotationDetails(build.annotations.edges)); } return lines.join('\n'); } formatFailedBuild(build, options) { const lines = []; // Header line lines.push(this.formatHeader(build)); lines.push(this.formatCommitInfo(build)); lines.push(''); const allHints = []; // Annotation summary (first, as it appears first in UI) if (build.annotations?.edges?.length > 0) { lines.push(this.formatAnnotationSummary(build.annotations.edges)); } // Jobs summary if (build.jobs?.edges?.length > 0) { if (build.annotations?.edges?.length > 0) { lines.push(''); // Add space between annotations and steps } lines.push(this.formatJobSummary(build.jobs, build.state)); } // Show detailed job info if requested if (options?.jobs || options?.failed) { lines.push(''); lines.push(this.formatJobDetails(build.jobs?.edges, options)); } // Show annotations detail if requested if (options?.annotations) { lines.push(''); lines.push(this.formatAnnotationDetails(build.annotations.edges)); } // Collect all hints for more info const failedJobs = this.getFailedJobs(build.jobs?.edges); if (!options?.failed && failedJobs.length > 0) { allHints.push('Use --failed to show failure details'); } if (!options?.annotations && build.annotations?.edges?.length > 0) { allHints.push('Use --annotations to view annotation details'); } // Add hint about incomplete step data if truncated if (!options?.jobs && build.jobs?.pageInfo?.hasNextPage) { allHints.push('Use --jobs to fetch all step data (currently showing first 100 only)'); } // Display all hints together if (allHints.length > 0 && options?.tips !== false) { lines.push(''); lines.push(formatTips(allHints, TipStyle.GROUPED)); } return lines.join('\n'); } formatRunningBuild(build, options) { const lines = []; // Header line lines.push(this.formatHeader(build)); lines.push(this.formatCommitInfo(build)); lines.push(''); // Annotations first (if any) if (build.annotations?.edges?.length > 0) { lines.push(this.formatAnnotationSummary(build.annotations.edges)); } // Jobs summary with progress if (build.jobs?.edges?.length > 0) { if (build.annotations?.edges?.length > 0) { lines.push(''); // Add space between annotations and steps } lines.push(this.formatJobSummary(build.jobs, build.state)); } // Show running jobs const runningJobs = this.getRunningJobs(build.jobs?.edges); if (runningJobs.length > 0) { const labels = runningJobs.map(j => this.parseEmoji(j.node.label)).join(', '); lines.push(`${SEMANTIC_COLORS.info('Running')}: ${labels}`); } // Annotation summary if (build.annotations?.edges?.length > 0) { lines.push(''); lines.push(this.formatAnnotationSummary(build.annotations.edges)); } // Show job details if requested if (options?.jobs) { lines.push(''); lines.push(this.formatJobDetails(build.jobs?.edges, options)); } // Show annotations detail if requested if (options?.annotations) { lines.push(''); lines.push(this.formatAnnotationDetails(build.annotations.edges)); } return lines.join('\n'); } formatBlockedBuild(build, options) { const lines = []; // Header line lines.push(this.formatHeader(build)); lines.push(this.formatCommitInfo(build)); lines.push(''); // Annotations first (if any) if (build.annotations?.edges?.length > 0) { lines.push(this.formatAnnotationSummary(build.annotations.edges)); } // Jobs summary if (build.jobs?.edges?.length > 0) { if (build.annotations?.edges?.length > 0) { lines.push(''); // Add space between annotations and steps } lines.push(this.formatJobSummary(build.jobs, build.state)); } // Blocked information const blockedJobs = this.getBlockedJobs(build.jobs?.edges); if (blockedJobs.length > 0) { lines.push(''); lines.push(`${getProgressIcon('BLOCKED_MESSAGE')} Blocked: "${blockedJobs[0].node.label}" (manual unblock required)`); } // Show job details if requested if (options?.jobs) { lines.push(''); lines.push(this.formatJobDetails(build.jobs?.edges, options)); } // Show annotations detail if requested if (options?.annotations) { lines.push(''); lines.push(this.formatAnnotationDetails(build.annotations.edges)); } return lines.join('\n'); } formatCanceledBuild(build, options) { const lines = []; // Header line lines.push(this.formatHeader(build)); lines.push(this.formatCommitInfo(build)); lines.push(''); // Annotations first (if any) if (build.annotations?.edges?.length > 0) { lines.push(this.formatAnnotationSummary(build.annotations.edges)); } // Jobs summary if (build.jobs?.edges?.length > 0) { if (build.annotations?.edges?.length > 0) { lines.push(''); // Add space between annotations and steps } lines.push(this.formatJobSummary(build.jobs, build.state)); } // Canceled information if (build.createdBy) { lines.push(''); const creator = build.createdBy.name || build.createdBy.email; lines.push(`Canceled by: ${creator}`); } // Show job details if requested if (options?.jobs) { lines.push(''); lines.push(this.formatJobDetails(build.jobs?.edges, options)); } return lines.join('\n'); } formatDefaultBuild(build, options) { return this.formatPassedBuild(build, options); } formatFullDetails(build, options) { const lines = []; // Full header information lines.push(this.formatHeader(build)); lines.push(this.formatCommitInfo(build)); lines.push(''); // Build metadata lines.push('Build Details:'); lines.push(` URL: ${build.url}`); lines.push(` Organization: ${build.organization?.name || 'Unknown'}`); lines.push(` Pipeline: ${build.pipeline?.name || 'Unknown'}`); if (build.pullRequest) { // Try to construct PR URL from repository URL const repoUrl = build.pipeline?.repository?.url; if (repoUrl && repoUrl.includes('github.com')) { // Extract owner/repo from various GitHub URL formats const match = repoUrl.match(/github\.com[/:]([\w-]+)\/([\w-]+)/); if (match && build.pullRequest.id) { // Extract PR number from GraphQL ID if possible // GitHub PR IDs often contain the number const prUrl = `https://github.com/${match[1]}/${match[2]}/pull/${build.pullRequest.id}`; lines.push(` Pull Request: ${SEMANTIC_COLORS.url(prUrl)}`); } else { lines.push(` Pull Request: ${build.pullRequest.id}`); } } else { lines.push(` Pull Request: ${build.pullRequest.id}`); } } if (build.triggeredFrom) { lines.push(` Triggered from: ${build.triggeredFrom.pipeline?.name} #${build.triggeredFrom.number}`); } lines.push(''); // Steps section lines.push('Steps:'); lines.push(this.formatJobDetails(build.jobs?.edges, { ...options, full: true })); // Annotations section if (build.annotations?.edges?.length > 0) { lines.push(''); lines.push('Annotations:'); lines.push(this.formatAnnotationDetails(build.annotations.edges)); } return lines.join('\n'); } formatHeader(build) { const statusIcon = this.getStatusIcon(build.state); // Apply appropriate color to the icon based on the state const coloredIcon = this.colorizeStatusIcon(statusIcon, build.state); const stateFormatted = formatBuildStatus(build.state, { useSymbol: false }); const duration = this.formatDuration(build); // Get first line of commit message const message = build.message || 'No commit message'; const firstLineMessage = message.split('\n')[0]; const truncatedMessage = firstLineMessage.length > 80 ? firstLineMessage.substring(0, 77) + '...' : firstLineMessage; return `${coloredIcon} ${stateFormatted} ${truncatedMessage} ${SEMANTIC_COLORS.dim(`#${build.number}`)} ${SEMANTIC_COLORS.dim(duration)}`; } formatCommitInfo(build) { const shortSha = build.commit ? build.commit.substring(0, 7) : 'unknown'; const branch = SEMANTIC_COLORS.identifier(build.branch); const age = this.formatAge(build.createdAt); // Get author information const author = build.createdBy?.name || build.createdBy?.email || 'Unknown'; // Calculate indentation to align with commit message // Map each state to its proper indentation (icon + space + state text + space) const indentMap = { 'PASSED': 9, // ✓ PASSED 'FAILED': 9, // ✗ FAILED 'RUNNING': 10, // ⟳ RUNNING 'BLOCKED': 10, // ◼ BLOCKED 'CANCELED': 11, // ⊘ CANCELED 'SCHEDULED': 12, // ⏱ SCHEDULED 'SKIPPED': 10, // ⊙ SKIPPED }; const indent = ' '.repeat(indentMap[build.state] || 9); return `${indent}${author} • ${branch} • ${shortSha} • ${SEMANTIC_COLORS.dim(`Created ${age}`)}`; } formatAnnotationSummary(annotations) { if (!annotations || annotations.length === 0) { return ''; } const lines = []; const total = annotations.length; // Header with count const counts = this.countAnnotationsByStyle(annotations); const countParts = []; if (counts.ERROR > 0) countParts.push(SEMANTIC_COLORS.error(`${counts.ERROR} error${counts.ERROR > 1 ? 's' : ''}`)); if (counts.WARNING > 0) countParts.push(SEMANTIC_COLORS.warning(`${counts.WARNING} warning${counts.WARNING > 1 ? 's' : ''}`)); if (counts.INFO > 0) countParts.push(SEMANTIC_COLORS.info(`${counts.INFO} info`)); if (counts.SUCCESS > 0) countParts.push(SEMANTIC_COLORS.success(`${counts.SUCCESS} success`)); lines.push(`${getAnnotationIcon('DEFAULT')} ${SEMANTIC_COLORS.count(String(total))} annotation${total > 1 ? 's' : ''}: ${countParts.join(', ')}`); // List each annotation with style and context const grouped = this.groupAnnotationsByStyle(annotations); const styleOrder = ['ERROR', 'WARNING', 'INFO', 'SUCCESS']; for (const style of styleOrder) { if (grouped[style]) { for (const annotation of grouped[style]) { const icon = this.getAnnotationIcon(style); const context = annotation.node.context || 'default'; const styleColored = this.colorizeAnnotationStyle(style); lines.push(` ${icon} ${styleColored}: ${context}`); } } } return lines.join('\n'); } formatJobSummary(jobsData, buildState) { const jobs = jobsData?.edges; if (!jobs || jobs.length === 0) { return ''; } const lines = []; const jobStats = this.getJobStats(jobs); // Build summary parts based on job states const countParts = []; if (jobStats.failed > 0) countParts.push(SEMANTIC_COLORS.error(`${jobStats.failed} failed`)); if (jobStats.softFailed > 0) countParts.push(SEMANTIC_COLORS.warning(`▲ ${jobStats.softFailed} soft failure${jobStats.softFailed > 1 ? 's' : ''}`)); if (jobStats.passed > 0) countParts.push(SEMANTIC_COLORS.success(`${jobStats.passed} passed`)); if (jobStats.running > 0) countParts.push(SEMANTIC_COLORS.info(`${jobStats.running} running`)); if (jobStats.blocked > 0) countParts.push(SEMANTIC_COLORS.warning(`${jobStats.blocked} blocked`)); if (jobStats.canceled > 0) countParts.push(SEMANTIC_COLORS.muted(`${jobStats.canceled} canceled`)); // Use appropriate icon based on build state const icon = buildState === 'FAILED' ? getStateIcon('FAILED') : buildState === 'RUNNING' ? getStateIcon('RUNNING') : buildState === 'PASSED' ? getStateIcon('PASSED') : buildState === 'BLOCKED' ? getStateIcon('BLOCKED') : '•'; // Check if we have partial data const hasMorePages = jobsData?.pageInfo?.hasNextPage; const totalCount = jobsData?.count; if (hasMorePages) { const showing = jobs.length; const total = totalCount || `${showing}+`; lines.push(`${icon} Showing ${SEMANTIC_COLORS.count(String(showing))} of ${SEMANTIC_COLORS.count(String(total))} steps: ${countParts.join(', ')}`); lines.push(SEMANTIC_COLORS.warning('⚠️ Showing first 100 steps only (more available)')); lines.push(SEMANTIC_COLORS.dim(' → Use --jobs to fetch all step data and see accurate statistics')); } else { lines.push(`${icon} ${SEMANTIC_COLORS.count(String(jobStats.total))} step${jobStats.total > 1 ? 's' : ''}: ${countParts.join(', ')}`); } // Show failed jobs for FAILED builds if (buildState === 'FAILED' && jobStats.failed > 0) { const failedJobs = this.getFailedJobs(jobs); const jobGroups = this.groupJobsByLabel(failedJobs); const displayGroups = jobGroups.slice(0, 3); for (const group of displayGroups) { const label = this.parseEmoji(group.label); const icon = getStateIcon('FAILED'); const duration = group.count === 1 && group.jobs[0]?.node ? ` ${SEMANTIC_COLORS.dim(`- ran ${this.formatJobDuration(group.jobs[0].node)}`)}` : ''; if (group.parallelTotal > 0) { lines.push(` ${icon} ${label} ${SEMANTIC_COLORS.dim(`(${group.stateCounts.failed || 0}/${group.parallelTotal} failed)`)}`); } else if (group.count > 1) { lines.push(` ${icon} ${label} ${SEMANTIC_COLORS.dim(`(${group.stateCounts.failed || 0} failed)`)}`); } else { lines.push(` ${icon} ${label}${duration}`); } } if (jobGroups.length > 3) { lines.push(` ${SEMANTIC_COLORS.muted(`...and ${jobGroups.length - 3} more`)}`); } } // Show soft failed jobs for PASSED builds OR when there are soft failures if (jobStats.softFailed > 0) { const softFailedJobs = this.getSoftFailedJobs(jobs); const jobGroups = this.groupJobsByLabel(softFailedJobs); lines.push(''); const displayGroups = jobGroups.slice(0, 5); for (const group of displayGroups) { const label = this.parseEmoji(group.label); const icon = getStateIcon('SOFT_FAILED'); const duration = group.count === 1 && group.jobs[0]?.node ? ` - ran ${this.formatJobDuration(group.jobs[0].node)}` : ''; if (group.parallelTotal > 0) { lines.push(` ${icon} ${label} ${SEMANTIC_COLORS.dim(`(${group.stateCounts.failed || 0}/${group.parallelTotal} soft failed)`)}`); } else if (group.count > 1) { lines.push(` ${icon} ${label} ${SEMANTIC_COLORS.dim(`(${group.stateCounts.failed || 0} soft failed)`)}`); } else { lines.push(` ${icon} ${label}${duration}`); } } if (jobGroups.length > 5) { lines.push(` ${SEMANTIC_COLORS.muted(`...and ${jobGroups.length - 5} more`)}`); } } return lines.join('\n'); } formatAnnotationDetails(annotations) { const lines = []; const isAscii = useAscii(); const terminalWidth = termWidth(); // Box drawing characters const boxChars = isAscii ? { horizontal: '-', vertical: '|' } : { horizontal: '─', vertical: '│' }; // Create a horizontal divider with padding and centering const createDivider = (width = 80) => { const padding = 2; // 1 space on each side const maxWidth = Math.min(width, terminalWidth - padding); const dividerLength = Math.max(20, maxWidth - padding); // Minimum 20 chars const divider = boxChars.horizontal.repeat(dividerLength); // Center the divider within the terminal width const totalPadding = terminalWidth - dividerLength; const leftPadding = Math.floor(totalPadding / 2); const spaces = ' '.repeat(Math.max(0, leftPadding)); return SEMANTIC_COLORS.dim(spaces + divider); }; // Group annotations by style const grouped = this.groupAnnotationsByStyle(annotations); const styleOrder = ['ERROR', 'WARNING', 'INFO', 'SUCCESS']; let annotationIndex = 0; for (const style of styleOrder) { if (grouped[style]) { for (const annotation of grouped[style]) { // Add divider between annotations (but not before the first one) if (annotationIndex > 0) { lines.push(''); lines.push(createDivider()); lines.push(''); } const icon = this.getAnnotationIcon(style); const context = annotation.node.context || 'default'; const colorFn = this.getStyleColorFunction(style); // Single line header with pipe: "│ ℹ info: test-mapping-build" const pipe = colorFn(boxChars.vertical); const header = `${pipe} ${icon} ${style.toLowerCase()}: ${context}`; lines.push(header); // Add blank line with pipe for visual continuity lines.push(pipe); // Format the body HTML with proper HTML/markdown handling const body = htmlToText(annotation.node.body?.html || '', { wordwrap: 80, preserveNewlines: true }); // Add vertical pipes to the left of the body content for visual continuity // Use the same color as the header for the pipes const bodyLines = body.split('\n'); bodyLines.forEach((line) => { const paddedLine = line ? ` ${line}` : ''; lines.push(`${pipe}${paddedLine}`); }); annotationIndex++; } } } // Add summary footer for multiple annotations if (annotations.length > 1) { lines.push(''); lines.push(createDivider()); lines.push(''); lines.push(SEMANTIC_COLORS.dim(`${SEMANTIC_COLORS.count(annotations.length.toString())} annotations found`)); } return lines.join('\n').trim(); } getStyleColorFunction(style) { const styleColorMap = { 'ERROR': SEMANTIC_COLORS.error, 'WARNING': SEMANTIC_COLORS.warning, 'INFO': SEMANTIC_COLORS.info, 'SUCCESS': SEMANTIC_COLORS.success }; return styleColorMap[style] || ((s) => s); } formatJobDetails(jobs, options) { if (!jobs || jobs.length === 0) { return 'No steps found'; } const lines = []; const jobStats = this.getJobStats(jobs); // Summary line const parts = []; if (jobStats.passed > 0) parts.push(`${getStateIcon('PASSED')} ${jobStats.passed} passed`); if (jobStats.failed > 0) parts.push(`${getStateIcon('FAILED')} ${jobStats.failed} failed`); if (jobStats.softFailed > 0) parts.push(`${getStateIcon('SOFT_FAILED')} ${jobStats.softFailed} soft failed`); if (jobStats.running > 0) parts.push(`${getStateIcon('RUNNING')} ${jobStats.running} running`); if (jobStats.blocked > 0) parts.push(`${getStateIcon('BLOCKED')} ${jobStats.blocked} blocked`); lines.push(`Steps: ${parts.join(' ')}`); lines.push(''); // Filter jobs based on options let filteredJobs = jobs; if (options?.failed) { // Include both hard and soft failures filteredJobs = [...this.getFailedJobs(jobs), ...this.getSoftFailedJobs(jobs)]; } // Group jobs by state first const grouped = this.groupJobsByState(filteredJobs); // Display order: Failed, Soft Failed, Passed, Running, Blocked const stateOrder = ['Failed', 'Soft Failed', 'Passed', 'Running', 'Blocked']; for (const state of stateOrder) { const stateJobs = grouped[state]; if (!stateJobs || stateJobs.length === 0) continue; const icon = this.getJobStateIcon(state); const stateColored = this.colorizeJobState(state); // Collapse parallel jobs with same label const collapsedGroups = this.collapseParallelJobs(stateJobs); lines.push(`${icon} ${stateColored} (${SEMANTIC_COLORS.count(String(stateJobs.length))}):`); for (const group of collapsedGroups) { if (group.isParallelGroup && group.jobs.length > 1) { // Collapsed parallel group display const label = this.parseEmoji(group.label); const total = group.parallelTotal || group.jobs.length; const passedCount = group.jobs.filter(j => this.isJobPassed(j.node)).length; const failedCount = group.jobs.filter(j => this.isJobFailed(j.node) || this.isJobSoftFailed(j.node)).length; // Show summary line for parallel group if (failedCount > 0) { const coloredLabel = this.colorizeJobLabel(state, label); lines.push(` ${coloredLabel} ${SEMANTIC_COLORS.dim(`(${passedCount}/${total} passed, ${failedCount} failed)`)}`); // Show failed steps individually const failedJobs = group.jobs.filter(j => this.isJobFailed(j.node) || this.isJobSoftFailed(j.node)); for (const job of failedJobs) { const duration = this.formatJobDuration(job.node); const parallelInfo = job.node.parallelGroupIndex !== undefined ? ` ${SEMANTIC_COLORS.dim(`[Parallel: ${job.node.parallelGroupIndex + 1}/${job.node.parallelGroupTotal}]`)}` : ''; const failType = this.isJobSoftFailed(job.node) ? 'Soft Failed' : 'Failed'; const failColor = this.isJobSoftFailed(job.node) ? SEMANTIC_COLORS.warning : SEMANTIC_COLORS.error; lines.push(` ${failColor('↳ ' + failType)}: ${SEMANTIC_COLORS.dim(duration)}${parallelInfo}`); } } else { // All passed/running/blocked - just show summary const avgDuration = this.calculateAverageDuration(group.jobs); const coloredLabel = this.colorizeJobLabel(state, label); lines.push(` ${coloredLabel} ${SEMANTIC_COLORS.dim(`(${total} parallel steps, avg: ${avgDuration})`)}`); } } else { // Single job or non-parallel group const job = group.jobs[0]; const label = this.parseEmoji(job.node.label); const duration = this.formatJobDuration(job.node); const coloredLabel = this.colorizeJobLabel(state, label); const parallelInfo = (job.node.parallelGroupIndex !== undefined && job.node.parallelGroupTotal) ? ` ${SEMANTIC_COLORS.dim(`[Parallel: ${job.node.parallelGroupIndex + 1}/${job.node.parallelGroupTotal}]`)}` : ''; lines.push(` ${coloredLabel} ${SEMANTIC_COLORS.dim(`(${duration})`)}${parallelInfo}`); // Show additional details if --jobs or --full and single step if ((options?.jobs || options?.full) && !group.isParallelGroup) { if (job.node.retried) { lines.push(` ${SEMANTIC_COLORS.warning(`${getProgressIcon('RETRY')} Retried`)}`); } } } } lines.push(''); } return lines.join('\n').trim(); } groupJobsByLabel(jobs) { const groups = new Map(); for (const job of jobs) { const fullLabel = job.node.label || 'Unnamed job'; // Strip parallel job index from label for grouping // e.g., "deposit_and_filing_schedule_calculator rspec (1/22)" -> "deposit_and_filing_schedule_calculator rspec" const baseLabel = fullLabel.replace(/\s*\(\d+\/\d+\)\s*$/, '').trim(); if (!groups.has(baseLabel)) { groups.set(baseLabel, { label: baseLabel, count: 0, jobs: [], parallelTotal: 0, stateCounts: { failed: 0, broken: 0, notStarted: 0, passed: 0, other: 0 } }); } const group = groups.get(baseLabel); group.count++; group.jobs.push(job); // Track the maximum parallel total for this job group if (job.node.parallelGroupTotal && job.node.parallelGroupTotal > group.parallelTotal) { group.parallelTotal = job.node.parallelGroupTotal; } // Count by state const state = job.node.state?.toUpperCase(); // Use exit status as source of truth when available // Note: exitStatus comes as a string from Buildkite API if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) { const exitCode = parseInt(job.node.exitStatus, 10); if (exitCode === 0) { group.stateCounts.passed++; } else { group.stateCounts.failed++; } } else if (!job.node.startedAt) { group.stateCounts.notStarted++; } else if (state === 'FINISHED' || state === 'COMPLETED') { // For finished jobs without exit status, check passed field if (job.node.passed === true) { group.stateCounts.passed++; } else if (job.node.passed === false) { group.stateCounts.failed++; } else { group.stateCounts.other++; } } else if (state === 'FAILED') { group.stateCounts.failed++; } else if (state === 'BROKEN') { group.stateCounts.broken++; } else if (state === 'PASSED' || job.node.passed === true) { group.stateCounts.passed++; } else { group.stateCounts.other++; } } // Convert to array and sort by count (most failures first) return Array.from(groups.values()) .sort((a, b) => b.count - a.count); } formatDuration(build) { if (!build.startedAt) { return 'not started'; } const start = new Date(build.startedAt); const end = build.finishedAt ? new Date(build.finishedAt) : new Date(); const durationMs = end.getTime() - start.getTime(); const minutes = Math.floor(durationMs / 60000); const seconds = Math.floor((durationMs % 60000) / 1000); if (build.state === 'RUNNING') { return `${minutes}m ${seconds}s elapsed`; } return `${minutes}m ${seconds}s`; } formatJobDuration(job) { if (!job.startedAt) { return 'not started'; } const start = new Date(job.startedAt); const end = job.finishedAt ? new Date(job.finishedAt) : new Date(); const durationMs = end.getTime() - start.getTime(); const minutes = Math.floor(durationMs / 60000); const seconds = Math.floor((durationMs % 60000) / 1000); return `${minutes}m ${seconds}s`; } formatAge(createdAt) { return formatDistanceToNow(new Date(createdAt), { addSuffix: true }); } colorizeJobState(state) { switch (state.toLowerCase()) { case 'failed': return SEMANTIC_COLORS.error(state); case 'soft failed': return SEMANTIC_COLORS.warning(state); case 'passed': return SEMANTIC_COLORS.success(state); case 'running': return SEMANTIC_COLORS.info(state); case 'blocked': return SEMANTIC_COLORS.warning(state); case 'skipped': case 'canceled': return SEMANTIC_COLORS.muted(state); default: return state; } } getStatusIcon(state) { return getStateIcon(state); } getJobStateIcon(state) { if (state === 'Soft Failed') { return getStateIcon('SOFT_FAILED'); } return getStateIcon(state); } getAnnotationIcon(style) { return getAnnotationIcon(style); } colorizeAnnotationStyle(style) { switch (style.toUpperCase()) { case 'ERROR': return SEMANTIC_COLORS.error(style.toLowerCase()); case 'WARNING': return SEMANTIC_COLORS.warning(style.toLowerCase()); case 'INFO': return SEMANTIC_COLORS.info(style.toLowerCase()); case 'SUCCESS': return SEMANTIC_COLORS.success(style.toLowerCase()); default: return style.toLowerCase(); } } colorizeStatusIcon(icon, state) { const upperState = state.toUpperCase(); const theme = BUILD_STATUS_THEME[upperState]; if (!theme) { return SEMANTIC_COLORS.muted(icon); } return theme.color(icon); } getJobStats(jobs) { const stats = { total: jobs?.length || 0, passed: 0, failed: 0, softFailed: 0, running: 0, blocked: 0, skipped: 0, canceled: 0, queued: 0, completed: 0 }; if (!jobs) return stats; for (const job of jobs) { const state = job.node.state?.toUpperCase() || ''; // If we have an exit status, use that as the source of truth if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) { const exitCode = parseInt(job.node.exitStatus, 10); if (exitCode === 0) { stats.passed++; stats.completed++; } else { // Non-zero exit: check if soft failure if (job.node.softFailed === true) { stats.softFailed++; } else { stats.failed++; } stats.completed++; } } else if (state === 'RUNNING') { stats.running++; } else if (state === 'BLOCKED') { stats.blocked++; } else if (state === 'CANCELED' || state === 'CANCELLED') { stats.canceled++; stats.completed++; } else if (state === 'SKIPPED' || state === 'BROKEN') { stats.skipped++; stats.completed++; } else if (state === 'SCHEDULED' || state === 'ASSIGNED') { stats.queued++; } else if (state === 'FINISHED' || state === 'COMPLETED') { // For finished jobs without exit status, check passed field if (job.node.passed === true) { stats.passed++; stats.completed++; } else if (job.node.passed === false) { // Check softFailed for finished jobs too if (job.node.softFailed === true) { stats.softFailed++; } else { stats.failed++; } stats.completed++; } } else if (state === 'PASSED' || job.node.passed === true) { stats.passed++; stats.completed++; } else if (state === 'FAILED' || job.node.passed === false) { // Check softFailed for explicitly failed jobs if (job.node.softFailed === true) { stats.softFailed++; } else { stats.failed++; } stats.completed++; } } return stats; } getFailedJobs(jobs) { if (!jobs) return []; return jobs.filter(job => { const state = job.node.state?.toUpperCase(); // BROKEN jobs are skipped/not run, not failed if (state === 'BROKEN' || state === 'SKIPPED') { return false; } // If we have an exit status, use that as the source of truth if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) { const exitCode = parseInt(job.node.exitStatus, 10); // Only return hard failures (not soft failures) return exitCode !== 0 && job.node.softFailed !== true; } // For FINISHED jobs, check the passed field if (state === 'FINISHED') { return job.node.passed === false && job.node.softFailed !== true; } // Otherwise check if explicitly failed (and not soft) return state === 'FAILED' && job.node.softFailed !== true; }); } getSoftFailedJobs(jobs) { if (!jobs) return []; return jobs.filter(job => { const state = job.node.state?.toUpperCase(); // BROKEN jobs are skipped/not run, not failed if (state === 'BROKEN' || state === 'SKIPPED') { return false; } // If we have an exit status, use that as the source of truth if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) { const exitCode = parseInt(job.node.exitStatus, 10); return exitCode !== 0 && job.node.softFailed === true; } // For FINISHED jobs, check the passed field and softFailed if (state === 'FINISHED') { return job.node.passed === false && job.node.softFailed === true; } return false; }); } getRunningJobs(jobs) { if (!jobs) return []; return jobs.filter(job => job.node.state?.toLowerCase() === 'running'); } getBlockedJobs(jobs) { if (!jobs) return []; return jobs.filter(job => job.node.state?.toLowerCase() === 'blocked'); } groupJobsByState(jobs) { const grouped = { 'Failed': [], 'Soft Failed': [], 'Passed': [], 'Running': [], 'Blocked': [] }; if (!jobs) return grouped; for (const job of jobs) { const state = job.node.state?.toUpperCase() || ''; // If we have an exit status, use that as the source of truth if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) { const exitCode = parseInt(job.node.exitStatus, 10); if (exitCode === 0) { grouped['Passed'].push(job); } else { // Non-zero exit: check if soft failure if (job.node.softFailed === true) { grouped['Soft Failed'].push(job); } else { grouped['Failed'].push(job); } } } else if (state === 'RUNNING') { grouped['Running'].push(job); } else if (state === 'BLOCKED') { grouped['Blocked'].push(job); } else if (state === 'SKIPPED' || state === 'CANCELED' || state === 'BROKEN') { // Don't display skipped/broken/canceled jobs } else if (state === 'FINISHED' || state === 'COMPLETED') { // For finished jobs without exit status, check passed field if (job.node.passed === true) { grouped['Passed'].push(job); } else if (job.node.passed === false) { // Check softFailed if (job.node.softFailed === true) { grouped['Soft Failed'].push(job); } else { grouped['Failed'].push(job); } } } else if (state === 'PASSED' || job.node.passed === true) { grouped['Passed'].push(job); } else if (state === 'FAILED') { // Check softFailed for explicitly failed jobs if (job.node.softFailed === true) { grouped['Soft Failed'].push(job); } else { grouped['Failed'].push(job); } } } return grouped; } collapseParallelJobs(jobs) { const groups = new Map(); // Group jobs by label for (const job of jobs) { const label = job.node.label || 'Unnamed'; if (!groups.has(label)) { groups.set(label, []); } groups.get(label).push(job); } // Convert to array and determine if each group is a parallel group const result = []; for (const [label, groupJobs] of groups.entries()) { // Check if this is a parallel group (multiple jobs with same label and parallel info) const hasParallelInfo = groupJobs.some(j => j.node.parallelGroupIndex !== undefined && j.node.parallelGroupTotal !== undefined); const isParallelGroup = hasParallelInfo && groupJobs.length > 1; // Get the total from the first job if available const parallelTotal = groupJobs[0]?.node?.parallelGroupTotal; result.push({ label, jobs: groupJobs, isParallelGroup, parallelTotal }); } return result; } isJobPassed(job) { const state = job.state?.toUpperCase(); if (job.exitStatus !== null && job.exitStatus !== undefined) { return parseInt(job.exitStatus, 10) === 0; } if (state === 'PASSED') return true; if (state === 'FINISHED' || state === 'COMPLETED') { return job.passed === true; } return job.passed === true; } isJobFailed(job) { const state = job.state?.toUpperCase(); if (job.exitStatus !== null && job.exitStatus !== undefined) { return parseInt(job.exitStatus, 10) !== 0; } if (state === 'FAILED') return true; if (state === 'FINISHED' || state === 'COMPLETED') { return job.passed === false; } return false; } isJobSoftFailed(job) { const state = job.state?.toUpperCase(); if (job.exitStatus !== null && job.exitStatus !== undefined) { return parseInt(job.exitStatus, 10) !== 0 && job.softFailed === true; } if (state === 'FINISHED' || state === 'COMPLETED') { return job.passed === false && job.softFailed === true; } return false; } colorizeJobLabel(state, label) { switch (state) { case 'Failed': return SEMANTIC_COLORS.error(label); case 'Soft Failed': return SEMANTIC_COLORS.warning(label); case 'Passed': return SEMANTIC_COLORS.success(label); case 'Running': return SEMANTIC_COLORS.info(label); case 'Blocked': return SEMANTIC_COLORS.warning(label); default: return label; } } calculateAverageDuration(jobs) { const durationsMs = jobs .filter(j => j.node.startedAt && j.node.finishedAt) .map(j => { const start = new Date(j.node.startedAt).getTime(); const end = new Date(j.node.finishedAt).getTime(); return end - start; }); if (durationsMs.length === 0) { return 'unknown'; } const avgMs = durationsMs.reduce((a, b) => a + b, 0) / durationsMs.length; const avgSeconds = Math.floor(avgMs / 1000); if (avgSeconds < 60) { return `${avgSeconds}s`; } const minutes = Math.floor(avgSeconds / 60); const seconds = avgSeconds % 60; if (minutes < 60) { return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; } countAnnotationsByStyle(annotations) { const counts = { ERROR: 0, WARNING: 0, INFO: 0, SUCCESS: 0 }; for (const annotation of annotations) { const style = annotation.node.style?.toUpperCase() || 'INFO'; if (style in counts) { counts[style]++; }