bktide 1.0.1755568192 → 1.0.1755655078

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,8 @@ import { BaseBuildDetailFormatter } from './Formatter.js';
2
2
  import { formatDistanceToNow } from 'date-fns';
3
3
  import { htmlToText } from 'html-to-text';
4
4
  import { formatEmptyState, formatError, SEMANTIC_COLORS, formatBuildStatus, formatTips, TipStyle, getStateIcon, getAnnotationIcon, getProgressIcon, BUILD_STATUS_THEME } from '../../ui/theme.js';
5
+ import { useAscii } from '../../ui/symbols.js';
6
+ import { termWidth } from '../../ui/width.js';
5
7
  // Standard emoji mappings only
6
8
  // Only map universally recognized emoji codes, not Buildkite-specific ones
7
9
  const STANDARD_EMOJI = {
@@ -140,10 +142,17 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
140
142
  // Header line
141
143
  lines.push(this.formatHeader(build));
142
144
  lines.push(this.formatCommitInfo(build));
145
+ lines.push(''); // Blank line after commit info
143
146
  // Show annotations summary if present
144
147
  if (build.annotations?.edges?.length > 0) {
145
- lines.push('');
146
148
  lines.push(this.formatAnnotationSummary(build.annotations.edges));
149
+ }
150
+ // Jobs summary
151
+ if (build.jobs?.edges?.length > 0) {
152
+ if (build.annotations?.edges?.length > 0) {
153
+ lines.push(''); // Add space between annotations and steps
154
+ }
155
+ lines.push(this.formatJobSummary(build.jobs, build.state));
147
156
  if (!options?.annotations) {
148
157
  lines.push('');
149
158
  const tips = formatTips(['Use --annotations to view annotation details'], TipStyle.GROUPED);
@@ -163,18 +172,18 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
163
172
  lines.push(this.formatHeader(build));
164
173
  lines.push(this.formatCommitInfo(build));
165
174
  lines.push('');
166
- // Failed jobs summary
167
- const failedJobs = this.getFailedJobs(build.jobs?.edges);
168
175
  const allHints = [];
169
- if (failedJobs.length > 0) {
170
- const { summary, hints } = this.formatFailedJobsSummaryWithHints(failedJobs, options);
171
- lines.push(summary);
172
- allHints.push(...hints);
173
- }
174
- // Annotation summary
176
+ // Annotation summary (first, as it appears first in UI)
175
177
  if (build.annotations?.edges?.length > 0) {
176
178
  lines.push(this.formatAnnotationSummary(build.annotations.edges));
177
179
  }
180
+ // Jobs summary
181
+ if (build.jobs?.edges?.length > 0) {
182
+ if (build.annotations?.edges?.length > 0) {
183
+ lines.push(''); // Add space between annotations and steps
184
+ }
185
+ lines.push(this.formatJobSummary(build.jobs, build.state));
186
+ }
178
187
  // Show detailed job info if requested
179
188
  if (options?.jobs || options?.failed) {
180
189
  lines.push('');
@@ -186,12 +195,17 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
186
195
  lines.push(this.formatAnnotationDetails(build.annotations.edges));
187
196
  }
188
197
  // Collect all hints for more info
198
+ const failedJobs = this.getFailedJobs(build.jobs?.edges);
189
199
  if (!options?.failed && failedJobs.length > 0) {
190
200
  allHints.push('Use --failed to show failure details');
191
201
  }
192
202
  if (!options?.annotations && build.annotations?.edges?.length > 0) {
193
203
  allHints.push('Use --annotations to view annotation details');
194
204
  }
205
+ // Add hint about incomplete step data if truncated
206
+ if (!options?.jobs && build.jobs?.pageInfo?.hasNextPage) {
207
+ allHints.push('Use --jobs to fetch all step data (currently showing first 100 only)');
208
+ }
195
209
  // Display all hints together
196
210
  if (allHints.length > 0) {
197
211
  lines.push('');
@@ -205,9 +219,17 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
205
219
  lines.push(this.formatHeader(build));
206
220
  lines.push(this.formatCommitInfo(build));
207
221
  lines.push('');
208
- // Progress information
209
- const jobStats = this.getJobStats(build.jobs?.edges);
210
- lines.push(`Progress: ${SEMANTIC_COLORS.count(String(jobStats.completed))}/${jobStats.total} complete, ${SEMANTIC_COLORS.info(String(jobStats.running))} running, ${jobStats.queued} queued`);
222
+ // Annotations first (if any)
223
+ if (build.annotations?.edges?.length > 0) {
224
+ lines.push(this.formatAnnotationSummary(build.annotations.edges));
225
+ }
226
+ // Jobs summary with progress
227
+ if (build.jobs?.edges?.length > 0) {
228
+ if (build.annotations?.edges?.length > 0) {
229
+ lines.push(''); // Add space between annotations and steps
230
+ }
231
+ lines.push(this.formatJobSummary(build.jobs, build.state));
232
+ }
211
233
  // Show running jobs
212
234
  const runningJobs = this.getRunningJobs(build.jobs?.edges);
213
235
  if (runningJobs.length > 0) {
@@ -237,20 +259,22 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
237
259
  lines.push(this.formatHeader(build));
238
260
  lines.push(this.formatCommitInfo(build));
239
261
  lines.push('');
262
+ // Annotations first (if any)
263
+ if (build.annotations?.edges?.length > 0) {
264
+ lines.push(this.formatAnnotationSummary(build.annotations.edges));
265
+ }
266
+ // Jobs summary
267
+ if (build.jobs?.edges?.length > 0) {
268
+ if (build.annotations?.edges?.length > 0) {
269
+ lines.push(''); // Add space between annotations and steps
270
+ }
271
+ lines.push(this.formatJobSummary(build.jobs, build.state));
272
+ }
240
273
  // Blocked information
241
274
  const blockedJobs = this.getBlockedJobs(build.jobs?.edges);
242
275
  if (blockedJobs.length > 0) {
243
- lines.push(`${getProgressIcon('BLOCKED_MESSAGE')} Blocked: "${blockedJobs[0].node.label}" (manual unblock required)`);
244
- }
245
- // Show jobs summary
246
- const jobStats = this.getJobStats(build.jobs?.edges);
247
- if (jobStats.completed > 0) {
248
- lines.push(`${getStateIcon('PASSED')} ${jobStats.completed} jobs passed before block`);
249
- }
250
- // Annotation summary
251
- if (build.annotations?.edges?.length > 0) {
252
276
  lines.push('');
253
- lines.push(this.formatAnnotationSummary(build.annotations.edges));
277
+ lines.push(`${getProgressIcon('BLOCKED_MESSAGE')} Blocked: "${blockedJobs[0].node.label}" (manual unblock required)`);
254
278
  }
255
279
  // Show job details if requested
256
280
  if (options?.jobs) {
@@ -270,14 +294,23 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
270
294
  lines.push(this.formatHeader(build));
271
295
  lines.push(this.formatCommitInfo(build));
272
296
  lines.push('');
297
+ // Annotations first (if any)
298
+ if (build.annotations?.edges?.length > 0) {
299
+ lines.push(this.formatAnnotationSummary(build.annotations.edges));
300
+ }
301
+ // Jobs summary
302
+ if (build.jobs?.edges?.length > 0) {
303
+ if (build.annotations?.edges?.length > 0) {
304
+ lines.push(''); // Add space between annotations and steps
305
+ }
306
+ lines.push(this.formatJobSummary(build.jobs, build.state));
307
+ }
273
308
  // Canceled information
274
309
  if (build.createdBy) {
310
+ lines.push('');
275
311
  const creator = build.createdBy.name || build.createdBy.email;
276
312
  lines.push(`Canceled by: ${creator}`);
277
313
  }
278
- // Show jobs summary
279
- const jobStats = this.getJobStats(build.jobs?.edges);
280
- lines.push(`Completed: ${jobStats.completed}/${jobStats.total} jobs before cancellation`);
281
314
  // Show job details if requested
282
315
  if (options?.jobs) {
283
316
  lines.push('');
@@ -323,8 +356,8 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
323
356
  lines.push(` Triggered from: ${build.triggeredFrom.pipeline?.name} #${build.triggeredFrom.number}`);
324
357
  }
325
358
  lines.push('');
326
- // Jobs section
327
- lines.push('Jobs:');
359
+ // Steps section
360
+ lines.push('Steps:');
328
361
  lines.push(this.formatJobDetails(build.jobs?.edges, { ...options, full: true }));
329
362
  // Annotations section
330
363
  if (build.annotations?.edges?.length > 0) {
@@ -340,15 +373,31 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
340
373
  const coloredIcon = this.colorizeStatusIcon(statusIcon, build.state);
341
374
  const stateFormatted = formatBuildStatus(build.state, { useSymbol: false });
342
375
  const duration = this.formatDuration(build);
343
- const age = this.formatAge(build.createdAt);
344
- const branch = SEMANTIC_COLORS.identifier(build.branch);
345
- return `${coloredIcon} ${SEMANTIC_COLORS.label(`#${build.number}`)} ${stateFormatted} • ${duration} • ${branch} • ${age}`;
376
+ // Get first line of commit message
377
+ const message = build.message || 'No commit message';
378
+ const firstLineMessage = message.split('\n')[0];
379
+ const truncatedMessage = firstLineMessage.length > 80 ? firstLineMessage.substring(0, 77) + '...' : firstLineMessage;
380
+ return `${coloredIcon} ${stateFormatted} ${truncatedMessage} ${SEMANTIC_COLORS.dim(`#${build.number}`)} ${SEMANTIC_COLORS.dim(duration)}`;
346
381
  }
347
382
  formatCommitInfo(build) {
348
383
  const shortSha = build.commit ? build.commit.substring(0, 7) : 'unknown';
349
- const message = build.message || 'No commit message';
350
- const truncatedMessage = message.length > 60 ? message.substring(0, 57) + '...' : message;
351
- return ` "${truncatedMessage}" (${shortSha})`;
384
+ const branch = SEMANTIC_COLORS.identifier(build.branch);
385
+ const age = this.formatAge(build.createdAt);
386
+ // Get author information
387
+ const author = build.createdBy?.name || build.createdBy?.email || 'Unknown';
388
+ // Calculate indentation to align with commit message
389
+ // Map each state to its proper indentation (icon + space + state text + space)
390
+ const indentMap = {
391
+ 'PASSED': 9, // ✓ PASSED
392
+ 'FAILED': 9, // ✗ FAILED
393
+ 'RUNNING': 10, // ⟳ RUNNING
394
+ 'BLOCKED': 10, // ◼ BLOCKED
395
+ 'CANCELED': 11, // ⊘ CANCELED
396
+ 'SCHEDULED': 12, // ⏱ SCHEDULED
397
+ 'SKIPPED': 10, // ⊙ SKIPPED
398
+ };
399
+ const indent = ' '.repeat(indentMap[build.state] || 9);
400
+ return `${indent}${author} • ${branch} • ${shortSha} • ${SEMANTIC_COLORS.dim(`Created ${age}`)}`;
352
401
  }
353
402
  formatAnnotationSummary(annotations) {
354
403
  if (!annotations || annotations.length === 0) {
@@ -383,33 +432,156 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
383
432
  }
384
433
  return lines.join('\n');
385
434
  }
435
+ formatJobSummary(jobsData, buildState) {
436
+ const jobs = jobsData?.edges;
437
+ if (!jobs || jobs.length === 0) {
438
+ return '';
439
+ }
440
+ const lines = [];
441
+ const jobStats = this.getJobStats(jobs);
442
+ // Build summary parts based on job states
443
+ const countParts = [];
444
+ if (jobStats.failed > 0)
445
+ countParts.push(SEMANTIC_COLORS.error(`${jobStats.failed} failed`));
446
+ if (jobStats.passed > 0)
447
+ countParts.push(SEMANTIC_COLORS.success(`${jobStats.passed} passed`));
448
+ if (jobStats.running > 0)
449
+ countParts.push(SEMANTIC_COLORS.info(`${jobStats.running} running`));
450
+ if (jobStats.blocked > 0)
451
+ countParts.push(SEMANTIC_COLORS.warning(`${jobStats.blocked} blocked`));
452
+ // Don't show skipped jobs
453
+ if (jobStats.canceled > 0)
454
+ countParts.push(SEMANTIC_COLORS.muted(`${jobStats.canceled} canceled`));
455
+ // Use appropriate icon based on build state
456
+ const icon = buildState === 'FAILED' ? getStateIcon('FAILED') :
457
+ buildState === 'RUNNING' ? getStateIcon('RUNNING') :
458
+ buildState === 'PASSED' ? getStateIcon('PASSED') :
459
+ buildState === 'BLOCKED' ? getStateIcon('BLOCKED') : '•';
460
+ // Check if we have partial data
461
+ const hasMorePages = jobsData?.pageInfo?.hasNextPage;
462
+ const totalCount = jobsData?.count;
463
+ if (hasMorePages) {
464
+ const showing = jobs.length;
465
+ const total = totalCount || `${showing}+`;
466
+ lines.push(`${icon} Showing ${SEMANTIC_COLORS.count(String(showing))} of ${SEMANTIC_COLORS.count(String(total))} steps: ${countParts.join(', ')}`);
467
+ lines.push(SEMANTIC_COLORS.warning('⚠️ Showing first 100 steps only (more available)'));
468
+ lines.push(SEMANTIC_COLORS.dim(' → Use --jobs to fetch all step data and see accurate statistics'));
469
+ }
470
+ else {
471
+ lines.push(`${icon} ${SEMANTIC_COLORS.count(String(jobStats.total))} step${jobStats.total > 1 ? 's' : ''}: ${countParts.join(', ')}`);
472
+ }
473
+ // For failed builds, show the specific failed job names
474
+ if (buildState === 'FAILED') {
475
+ const failedJobs = this.getFailedJobs(jobs);
476
+ const jobGroups = this.groupJobsByLabel(failedJobs);
477
+ // Show up to 3 failed job types
478
+ const displayGroups = jobGroups.slice(0, 3);
479
+ for (const group of displayGroups) {
480
+ const label = this.parseEmoji(group.label);
481
+ const icon = getStateIcon('FAILED');
482
+ // Get duration for display
483
+ const duration = group.count === 1 && group.jobs[0]?.node
484
+ ? ` ${SEMANTIC_COLORS.dim(`- ran ${this.formatJobDuration(group.jobs[0].node)}`)}`
485
+ : '';
486
+ if (group.parallelTotal > 0) {
487
+ lines.push(` ${icon} ${label} ${SEMANTIC_COLORS.dim(`(${group.stateCounts.failed || 0}/${group.parallelTotal} failed)`)}`);
488
+ }
489
+ else if (group.count > 1) {
490
+ lines.push(` ${icon} ${label} ${SEMANTIC_COLORS.dim(`(${group.stateCounts.failed || 0} failed)`)}`);
491
+ }
492
+ else {
493
+ lines.push(` ${icon} ${label}${duration}`);
494
+ }
495
+ }
496
+ if (jobGroups.length > 3) {
497
+ lines.push(` ${SEMANTIC_COLORS.muted(`...and ${jobGroups.length - 3} more`)}`);
498
+ }
499
+ }
500
+ return lines.join('\n');
501
+ }
386
502
  formatAnnotationDetails(annotations) {
387
503
  const lines = [];
504
+ const isAscii = useAscii();
505
+ const terminalWidth = termWidth();
506
+ // Box drawing characters
507
+ const boxChars = isAscii ? {
508
+ horizontal: '-',
509
+ vertical: '|'
510
+ } : {
511
+ horizontal: '─',
512
+ vertical: '│'
513
+ };
514
+ // Create a horizontal divider with padding and centering
515
+ const createDivider = (width = 80) => {
516
+ const padding = 2; // 1 space on each side
517
+ const maxWidth = Math.min(width, terminalWidth - padding);
518
+ const dividerLength = Math.max(20, maxWidth - padding); // Minimum 20 chars
519
+ const divider = boxChars.horizontal.repeat(dividerLength);
520
+ // Center the divider within the terminal width
521
+ const totalPadding = terminalWidth - dividerLength;
522
+ const leftPadding = Math.floor(totalPadding / 2);
523
+ const spaces = ' '.repeat(Math.max(0, leftPadding));
524
+ return SEMANTIC_COLORS.dim(spaces + divider);
525
+ };
388
526
  // Group annotations by style
389
527
  const grouped = this.groupAnnotationsByStyle(annotations);
390
528
  const styleOrder = ['ERROR', 'WARNING', 'INFO', 'SUCCESS'];
529
+ let annotationIndex = 0;
391
530
  for (const style of styleOrder) {
392
531
  if (grouped[style]) {
393
532
  for (const annotation of grouped[style]) {
533
+ // Add divider between annotations (but not before the first one)
534
+ if (annotationIndex > 0) {
535
+ lines.push('');
536
+ lines.push(createDivider());
537
+ lines.push('');
538
+ }
394
539
  const icon = this.getAnnotationIcon(style);
395
540
  const context = annotation.node.context || 'default';
396
- const styleColored = this.colorizeAnnotationStyle(style);
397
- // When showing annotation details, always show the body text
398
- lines.push(`${icon} ${styleColored}: ${context}`);
541
+ const colorFn = this.getStyleColorFunction(style);
542
+ // Single line header with pipe: "│ info: test-mapping-build"
543
+ const pipe = colorFn(boxChars.vertical);
544
+ const header = `${pipe} ${icon} ${style.toLowerCase()}: ${context}`;
545
+ lines.push(header);
546
+ // Add blank line with pipe for visual continuity
547
+ lines.push(pipe);
548
+ // Format the body HTML with proper HTML/markdown handling
399
549
  const body = htmlToText(annotation.node.body?.html || '', {
400
550
  wordwrap: 80,
401
551
  preserveNewlines: true
402
552
  });
403
- lines.push(body.split('\n').map(l => ` ${l}`).join('\n'));
404
- lines.push('');
553
+ // Add vertical pipes to the left of the body content for visual continuity
554
+ // Use the same color as the header for the pipes
555
+ const bodyLines = body.split('\n');
556
+ bodyLines.forEach((line) => {
557
+ const paddedLine = line ? ` ${line}` : '';
558
+ lines.push(`${pipe}${paddedLine}`);
559
+ });
560
+ annotationIndex++;
405
561
  }
406
562
  }
407
563
  }
564
+ // Add summary footer for multiple annotations
565
+ if (annotations.length > 1) {
566
+ lines.push('');
567
+ lines.push(createDivider());
568
+ lines.push('');
569
+ lines.push(SEMANTIC_COLORS.dim(`${SEMANTIC_COLORS.count(annotations.length.toString())} annotations found`));
570
+ }
408
571
  return lines.join('\n').trim();
409
572
  }
573
+ getStyleColorFunction(style) {
574
+ const styleColorMap = {
575
+ 'ERROR': SEMANTIC_COLORS.error,
576
+ 'WARNING': SEMANTIC_COLORS.warning,
577
+ 'INFO': SEMANTIC_COLORS.info,
578
+ 'SUCCESS': SEMANTIC_COLORS.success
579
+ };
580
+ return styleColorMap[style] || ((s) => s);
581
+ }
410
582
  formatJobDetails(jobs, options) {
411
583
  if (!jobs || jobs.length === 0) {
412
- return 'No jobs found';
584
+ return 'No steps found';
413
585
  }
414
586
  const lines = [];
415
587
  const jobStats = this.getJobStats(jobs);
@@ -423,45 +595,83 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
423
595
  parts.push(`${getStateIcon('RUNNING')} ${jobStats.running} running`);
424
596
  if (jobStats.blocked > 0)
425
597
  parts.push(`${getStateIcon('BLOCKED')} ${jobStats.blocked} blocked`);
426
- if (jobStats.skipped > 0)
427
- parts.push(`${getStateIcon('SKIPPED')} ${jobStats.skipped} skipped`);
428
- lines.push(`Jobs: ${parts.join(' ')}`);
598
+ // Don't show skipped jobs in summary
599
+ lines.push(`Steps: ${parts.join(' ')}`);
429
600
  lines.push('');
430
601
  // Filter jobs based on options
431
602
  let filteredJobs = jobs;
432
603
  if (options?.failed) {
433
604
  filteredJobs = this.getFailedJobs(jobs);
434
605
  }
435
- // Group jobs by state
606
+ // Group jobs by state first
436
607
  const grouped = this.groupJobsByState(filteredJobs);
437
608
  for (const [state, stateJobs] of Object.entries(grouped)) {
438
609
  if (stateJobs.length === 0)
439
610
  continue;
440
611
  const icon = this.getJobStateIcon(state);
441
612
  const stateColored = this.colorizeJobState(state);
613
+ // Collapse parallel jobs with same label
614
+ const collapsedGroups = this.collapseParallelJobs(stateJobs);
442
615
  lines.push(`${icon} ${stateColored} (${SEMANTIC_COLORS.count(String(stateJobs.length))}):`);
443
- for (const job of stateJobs) {
444
- const label = this.parseEmoji(job.node.label);
445
- const duration = this.formatJobDuration(job.node);
446
- // Basic job line
447
- lines.push(` ${label} (${duration})`);
448
- // Show additional details if --jobs or --full
449
- if (options?.jobs || options?.full) {
450
- // Timing details
451
- if (job.node.startedAt) {
452
- const startTime = new Date(job.node.startedAt).toLocaleTimeString();
453
- const endTime = job.node.finishedAt
454
- ? new Date(job.node.finishedAt).toLocaleTimeString()
455
- : 'still running';
456
- lines.push(` ${SEMANTIC_COLORS.dim(`${getProgressIcon('TIMING')} ${startTime} → ${endTime}`)}`);
616
+ for (const group of collapsedGroups) {
617
+ if (group.isParallelGroup && group.jobs.length > 1) {
618
+ // Collapsed parallel group display
619
+ const label = this.parseEmoji(group.label);
620
+ const total = group.parallelTotal || group.jobs.length;
621
+ const passedCount = group.jobs.filter(j => this.isJobPassed(j.node)).length;
622
+ const failedCount = group.jobs.filter(j => this.isJobFailed(j.node)).length;
623
+ // Show summary line for parallel group
624
+ if (failedCount > 0) {
625
+ // If there are failures, show breakdown
626
+ // Apply state color to label
627
+ const coloredLabel = state === 'Failed' ? SEMANTIC_COLORS.error(label) :
628
+ state === 'Passed' ? SEMANTIC_COLORS.success(label) :
629
+ state === 'Running' ? SEMANTIC_COLORS.info(label) :
630
+ state === 'Blocked' ? SEMANTIC_COLORS.warning(label) : label;
631
+ lines.push(` ${coloredLabel} ${SEMANTIC_COLORS.dim(`(${passedCount}/${total} passed, ${failedCount} failed)`)}`);
632
+ // Show failed steps individually
633
+ const failedJobs = group.jobs.filter(j => this.isJobFailed(j.node));
634
+ for (const job of failedJobs) {
635
+ const duration = this.formatJobDuration(job.node);
636
+ const parallelInfo = job.node.parallelGroupIndex !== undefined
637
+ ? ` ${SEMANTIC_COLORS.dim(`[Parallel: ${job.node.parallelGroupIndex + 1}/${job.node.parallelGroupTotal}]`)}`
638
+ : '';
639
+ lines.push(` ${SEMANTIC_COLORS.error('↳ Failed')}: ${SEMANTIC_COLORS.dim(duration)}${parallelInfo}`);
640
+ }
457
641
  }
458
- // Parallel group info
459
- if (job.node.parallelGroupIndex !== undefined && job.node.parallelGroupTotal) {
460
- lines.push(` ${SEMANTIC_COLORS.dim(`${getProgressIcon('PARALLEL')} Parallel: ${job.node.parallelGroupIndex + 1}/${job.node.parallelGroupTotal}`)}`);
642
+ else {
643
+ // All passed/running/blocked - just show summary
644
+ const avgDuration = this.calculateAverageDuration(group.jobs);
645
+ // Apply state color to label
646
+ const coloredLabel = state === 'Passed' ? SEMANTIC_COLORS.success(label) :
647
+ state === 'Failed' ? SEMANTIC_COLORS.error(label) :
648
+ state === 'Running' ? SEMANTIC_COLORS.info(label) :
649
+ state === 'Blocked' ? SEMANTIC_COLORS.warning(label) : label;
650
+ lines.push(` ${coloredLabel} ${SEMANTIC_COLORS.dim(`(${total} parallel steps, avg: ${avgDuration})`)}`);
461
651
  }
462
- // Retry info
463
- if (job.node.retried) {
464
- lines.push(` ${SEMANTIC_COLORS.warning(`${getProgressIcon('RETRY')} Retried`)}`);
652
+ }
653
+ else {
654
+ // Single job or non-parallel group - display as before
655
+ const job = group.jobs[0];
656
+ const label = this.parseEmoji(job.node.label);
657
+ const duration = this.formatJobDuration(job.node);
658
+ // Apply state color to label
659
+ const coloredLabel = state === 'Passed' ? SEMANTIC_COLORS.success(label) :
660
+ state === 'Failed' ? SEMANTIC_COLORS.error(label) :
661
+ state === 'Running' ? SEMANTIC_COLORS.info(label) :
662
+ state === 'Blocked' ? SEMANTIC_COLORS.warning(label) : label;
663
+ // Add parallel info inline if present
664
+ const parallelInfo = (job.node.parallelGroupIndex !== undefined && job.node.parallelGroupTotal)
665
+ ? ` ${SEMANTIC_COLORS.dim(`[Parallel: ${job.node.parallelGroupIndex + 1}/${job.node.parallelGroupTotal}]`)}`
666
+ : '';
667
+ // Basic step line with optional parallel info
668
+ lines.push(` ${coloredLabel} ${SEMANTIC_COLORS.dim(`(${duration})`)}${parallelInfo}`);
669
+ // Show additional details if --jobs or --full and single step
670
+ if ((options?.jobs || options?.full) && !group.isParallelGroup) {
671
+ // Retry info
672
+ if (job.node.retried) {
673
+ lines.push(` ${SEMANTIC_COLORS.warning(`${getProgressIcon('RETRY')} Retried`)}`);
674
+ }
465
675
  }
466
676
  }
467
677
  }
@@ -469,67 +679,6 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
469
679
  }
470
680
  return lines.join('\n').trim();
471
681
  }
472
- formatFailedJobsSummaryWithHints(failedJobs, options) {
473
- const hints = [];
474
- const summary = this.formatFailedJobsSummary(failedJobs, options, hints);
475
- return { summary, hints };
476
- }
477
- formatFailedJobsSummary(failedJobs, options, hints) {
478
- const lines = [];
479
- // Group identical jobs by label
480
- const jobGroups = this.groupJobsByLabel(failedJobs);
481
- // Show all groups if --all-jobs, otherwise limit to 10
482
- const displayGroups = options?.allJobs
483
- ? jobGroups
484
- : jobGroups.slice(0, 10);
485
- for (const group of displayGroups) {
486
- const label = this.parseEmoji(group.label);
487
- if (group.count === 1) {
488
- const duration = this.formatJobDuration(group.jobs[0].node);
489
- lines.push(` ${SEMANTIC_COLORS.error('Failed')}: ${label} - ran ${duration}`);
490
- }
491
- else {
492
- // Multiple jobs with same label - show detailed breakdown
493
- const statusParts = [];
494
- if (group.stateCounts.failed > 0) {
495
- statusParts.push(`${group.stateCounts.failed} failed`);
496
- }
497
- if (group.stateCounts.broken > 0) {
498
- statusParts.push(`${group.stateCounts.broken} broken`);
499
- }
500
- if (group.stateCounts.notStarted > 0) {
501
- statusParts.push(`${group.stateCounts.notStarted} not started`);
502
- }
503
- if (group.stateCounts.passed > 0) {
504
- statusParts.push(`${group.stateCounts.passed} passed`);
505
- }
506
- if (group.stateCounts.other > 0) {
507
- statusParts.push(`${group.stateCounts.other} other`);
508
- }
509
- const statusInfo = statusParts.join(', ') || 'various states';
510
- // Show parallel info if it's a parallel job group
511
- const parallelInfo = group.parallelTotal > 0 ? ` (${group.count}/${group.parallelTotal} parallel)` : ` (${SEMANTIC_COLORS.count(String(group.count))} jobs)`;
512
- lines.push(` ${SEMANTIC_COLORS.error('Failed')}: ${label}${parallelInfo}: ${statusInfo}`);
513
- }
514
- }
515
- // Add summary if there are more job types and not showing all
516
- if (!options?.allJobs) {
517
- const remaining = jobGroups.length - displayGroups.length;
518
- if (remaining > 0) {
519
- lines.push(` ${SEMANTIC_COLORS.muted(`...and ${remaining} more job types`)}`);
520
- // If hints array is provided, add hint there; otherwise format inline
521
- if (hints) {
522
- hints.push('Use --all-jobs to show all jobs');
523
- }
524
- else {
525
- lines.push('');
526
- const tips = formatTips(['Use --all-jobs to show all jobs'], TipStyle.GROUPED);
527
- lines.push(tips);
528
- }
529
- }
530
- }
531
- return lines.join('\n');
532
- }
533
682
  groupJobsByLabel(jobs) {
534
683
  const groups = new Map();
535
684
  for (const job of jobs) {
@@ -688,6 +837,7 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
688
837
  running: 0,
689
838
  blocked: 0,
690
839
  skipped: 0,
840
+ canceled: 0,
691
841
  queued: 0,
692
842
  completed: 0
693
843
  };
@@ -714,7 +864,12 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
714
864
  else if (state === 'BLOCKED') {
715
865
  stats.blocked++;
716
866
  }
717
- else if (state === 'SKIPPED' || state === 'CANCELED') {
867
+ else if (state === 'CANCELED' || state === 'CANCELLED') {
868
+ stats.canceled++;
869
+ stats.completed++;
870
+ }
871
+ else if (state === 'SKIPPED' || state === 'BROKEN') {
872
+ // BROKEN jobs are functionally skipped - they don't run due to conditions not matching
718
873
  stats.skipped++;
719
874
  stats.completed++;
720
875
  }
@@ -736,7 +891,7 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
736
891
  stats.passed++;
737
892
  stats.completed++;
738
893
  }
739
- else if (state === 'FAILED' || state === 'BROKEN' || job.node.passed === false) {
894
+ else if (state === 'FAILED' || job.node.passed === false) {
740
895
  stats.failed++;
741
896
  stats.completed++;
742
897
  }
@@ -748,14 +903,22 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
748
903
  return [];
749
904
  return jobs.filter(job => {
750
905
  const state = job.node.state?.toUpperCase();
906
+ // BROKEN jobs are skipped/not run, not failed
907
+ if (state === 'BROKEN' || state === 'SKIPPED') {
908
+ return false;
909
+ }
751
910
  // If we have an exit status, use that as the source of truth
752
911
  // Note: exitStatus comes as a string from Buildkite API
753
912
  if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) {
754
913
  const exitCode = parseInt(job.node.exitStatus, 10);
755
914
  return exitCode !== 0;
756
915
  }
757
- // Otherwise fall back to state
758
- return state === 'FAILED' || state === 'BROKEN' || job.node.passed === false;
916
+ // For FINISHED jobs, check the passed field
917
+ if (state === 'FINISHED') {
918
+ return job.node.passed === false;
919
+ }
920
+ // Otherwise check if explicitly failed
921
+ return state === 'FAILED';
759
922
  });
760
923
  }
761
924
  getRunningJobs(jobs) {
@@ -773,8 +936,8 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
773
936
  'Failed': [],
774
937
  'Passed': [],
775
938
  'Running': [],
776
- 'Blocked': [],
777
- 'Skipped': []
939
+ 'Blocked': []
940
+ // Don't include Skipped - we don't display them
778
941
  };
779
942
  if (!jobs)
780
943
  return grouped;
@@ -797,8 +960,9 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
797
960
  else if (state === 'BLOCKED') {
798
961
  grouped['Blocked'].push(job);
799
962
  }
800
- else if (state === 'SKIPPED' || state === 'CANCELED') {
801
- grouped['Skipped'].push(job);
963
+ else if (state === 'SKIPPED' || state === 'CANCELED' || state === 'BROKEN') {
964
+ // Don't display skipped/broken/canceled jobs - they're not shown in Buildkite UI
965
+ // Skip these entirely
802
966
  }
803
967
  else if (state === 'FINISHED' || state === 'COMPLETED') {
804
968
  // For finished jobs without exit status, check passed field
@@ -812,12 +976,88 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
812
976
  else if (state === 'PASSED' || job.node.passed === true) {
813
977
  grouped['Passed'].push(job);
814
978
  }
815
- else if (state === 'FAILED' || state === 'BROKEN' || job.node.passed === false) {
979
+ else if (state === 'FAILED') {
816
980
  grouped['Failed'].push(job);
817
981
  }
818
982
  }
819
983
  return grouped;
820
984
  }
985
+ collapseParallelJobs(jobs) {
986
+ const groups = new Map();
987
+ // Group jobs by label
988
+ for (const job of jobs) {
989
+ const label = job.node.label || 'Unnamed';
990
+ if (!groups.has(label)) {
991
+ groups.set(label, []);
992
+ }
993
+ groups.get(label).push(job);
994
+ }
995
+ // Convert to array and determine if each group is a parallel group
996
+ const result = [];
997
+ for (const [label, groupJobs] of groups.entries()) {
998
+ // Check if this is a parallel group (multiple jobs with same label and parallel info)
999
+ const hasParallelInfo = groupJobs.some(j => j.node.parallelGroupIndex !== undefined && j.node.parallelGroupTotal !== undefined);
1000
+ const isParallelGroup = hasParallelInfo && groupJobs.length > 1;
1001
+ // Get the total from the first job if available
1002
+ const parallelTotal = groupJobs[0]?.node?.parallelGroupTotal;
1003
+ result.push({
1004
+ label,
1005
+ jobs: groupJobs,
1006
+ isParallelGroup,
1007
+ parallelTotal
1008
+ });
1009
+ }
1010
+ return result;
1011
+ }
1012
+ isJobPassed(job) {
1013
+ const state = job.state?.toUpperCase();
1014
+ if (job.exitStatus !== null && job.exitStatus !== undefined) {
1015
+ return parseInt(job.exitStatus, 10) === 0;
1016
+ }
1017
+ if (state === 'PASSED')
1018
+ return true;
1019
+ if (state === 'FINISHED' || state === 'COMPLETED') {
1020
+ return job.passed === true;
1021
+ }
1022
+ return job.passed === true;
1023
+ }
1024
+ isJobFailed(job) {
1025
+ const state = job.state?.toUpperCase();
1026
+ if (job.exitStatus !== null && job.exitStatus !== undefined) {
1027
+ return parseInt(job.exitStatus, 10) !== 0;
1028
+ }
1029
+ if (state === 'FAILED')
1030
+ return true;
1031
+ if (state === 'FINISHED' || state === 'COMPLETED') {
1032
+ return job.passed === false;
1033
+ }
1034
+ return false;
1035
+ }
1036
+ calculateAverageDuration(jobs) {
1037
+ const durationsMs = jobs
1038
+ .filter(j => j.node.startedAt && j.node.finishedAt)
1039
+ .map(j => {
1040
+ const start = new Date(j.node.startedAt).getTime();
1041
+ const end = new Date(j.node.finishedAt).getTime();
1042
+ return end - start;
1043
+ });
1044
+ if (durationsMs.length === 0) {
1045
+ return 'unknown';
1046
+ }
1047
+ const avgMs = durationsMs.reduce((a, b) => a + b, 0) / durationsMs.length;
1048
+ const avgSeconds = Math.floor(avgMs / 1000);
1049
+ if (avgSeconds < 60) {
1050
+ return `${avgSeconds}s`;
1051
+ }
1052
+ const minutes = Math.floor(avgSeconds / 60);
1053
+ const seconds = avgSeconds % 60;
1054
+ if (minutes < 60) {
1055
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
1056
+ }
1057
+ const hours = Math.floor(minutes / 60);
1058
+ const remainingMinutes = minutes % 60;
1059
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
1060
+ }
821
1061
  countAnnotationsByStyle(annotations) {
822
1062
  const counts = {
823
1063
  ERROR: 0,