bktide 1.0.1755568192 → 1.0.1755639164

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.
@@ -140,10 +140,17 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
140
140
  // Header line
141
141
  lines.push(this.formatHeader(build));
142
142
  lines.push(this.formatCommitInfo(build));
143
+ lines.push(''); // Blank line after commit info
143
144
  // Show annotations summary if present
144
145
  if (build.annotations?.edges?.length > 0) {
145
- lines.push('');
146
146
  lines.push(this.formatAnnotationSummary(build.annotations.edges));
147
+ }
148
+ // Jobs summary
149
+ if (build.jobs?.edges?.length > 0) {
150
+ if (build.annotations?.edges?.length > 0) {
151
+ lines.push(''); // Add space between annotations and steps
152
+ }
153
+ lines.push(this.formatJobSummary(build.jobs, build.state));
147
154
  if (!options?.annotations) {
148
155
  lines.push('');
149
156
  const tips = formatTips(['Use --annotations to view annotation details'], TipStyle.GROUPED);
@@ -163,18 +170,18 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
163
170
  lines.push(this.formatHeader(build));
164
171
  lines.push(this.formatCommitInfo(build));
165
172
  lines.push('');
166
- // Failed jobs summary
167
- const failedJobs = this.getFailedJobs(build.jobs?.edges);
168
173
  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
174
+ // Annotation summary (first, as it appears first in UI)
175
175
  if (build.annotations?.edges?.length > 0) {
176
176
  lines.push(this.formatAnnotationSummary(build.annotations.edges));
177
177
  }
178
+ // Jobs summary
179
+ if (build.jobs?.edges?.length > 0) {
180
+ if (build.annotations?.edges?.length > 0) {
181
+ lines.push(''); // Add space between annotations and steps
182
+ }
183
+ lines.push(this.formatJobSummary(build.jobs, build.state));
184
+ }
178
185
  // Show detailed job info if requested
179
186
  if (options?.jobs || options?.failed) {
180
187
  lines.push('');
@@ -186,12 +193,17 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
186
193
  lines.push(this.formatAnnotationDetails(build.annotations.edges));
187
194
  }
188
195
  // Collect all hints for more info
196
+ const failedJobs = this.getFailedJobs(build.jobs?.edges);
189
197
  if (!options?.failed && failedJobs.length > 0) {
190
198
  allHints.push('Use --failed to show failure details');
191
199
  }
192
200
  if (!options?.annotations && build.annotations?.edges?.length > 0) {
193
201
  allHints.push('Use --annotations to view annotation details');
194
202
  }
203
+ // Add hint about incomplete step data if truncated
204
+ if (!options?.jobs && build.jobs?.pageInfo?.hasNextPage) {
205
+ allHints.push('Use --jobs to fetch all step data (currently showing first 100 only)');
206
+ }
195
207
  // Display all hints together
196
208
  if (allHints.length > 0) {
197
209
  lines.push('');
@@ -205,9 +217,17 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
205
217
  lines.push(this.formatHeader(build));
206
218
  lines.push(this.formatCommitInfo(build));
207
219
  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`);
220
+ // Annotations first (if any)
221
+ if (build.annotations?.edges?.length > 0) {
222
+ lines.push(this.formatAnnotationSummary(build.annotations.edges));
223
+ }
224
+ // Jobs summary with progress
225
+ if (build.jobs?.edges?.length > 0) {
226
+ if (build.annotations?.edges?.length > 0) {
227
+ lines.push(''); // Add space between annotations and steps
228
+ }
229
+ lines.push(this.formatJobSummary(build.jobs, build.state));
230
+ }
211
231
  // Show running jobs
212
232
  const runningJobs = this.getRunningJobs(build.jobs?.edges);
213
233
  if (runningJobs.length > 0) {
@@ -237,20 +257,22 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
237
257
  lines.push(this.formatHeader(build));
238
258
  lines.push(this.formatCommitInfo(build));
239
259
  lines.push('');
260
+ // Annotations first (if any)
261
+ if (build.annotations?.edges?.length > 0) {
262
+ lines.push(this.formatAnnotationSummary(build.annotations.edges));
263
+ }
264
+ // Jobs summary
265
+ if (build.jobs?.edges?.length > 0) {
266
+ if (build.annotations?.edges?.length > 0) {
267
+ lines.push(''); // Add space between annotations and steps
268
+ }
269
+ lines.push(this.formatJobSummary(build.jobs, build.state));
270
+ }
240
271
  // Blocked information
241
272
  const blockedJobs = this.getBlockedJobs(build.jobs?.edges);
242
273
  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
274
  lines.push('');
253
- lines.push(this.formatAnnotationSummary(build.annotations.edges));
275
+ lines.push(`${getProgressIcon('BLOCKED_MESSAGE')} Blocked: "${blockedJobs[0].node.label}" (manual unblock required)`);
254
276
  }
255
277
  // Show job details if requested
256
278
  if (options?.jobs) {
@@ -270,14 +292,23 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
270
292
  lines.push(this.formatHeader(build));
271
293
  lines.push(this.formatCommitInfo(build));
272
294
  lines.push('');
295
+ // Annotations first (if any)
296
+ if (build.annotations?.edges?.length > 0) {
297
+ lines.push(this.formatAnnotationSummary(build.annotations.edges));
298
+ }
299
+ // Jobs summary
300
+ if (build.jobs?.edges?.length > 0) {
301
+ if (build.annotations?.edges?.length > 0) {
302
+ lines.push(''); // Add space between annotations and steps
303
+ }
304
+ lines.push(this.formatJobSummary(build.jobs, build.state));
305
+ }
273
306
  // Canceled information
274
307
  if (build.createdBy) {
308
+ lines.push('');
275
309
  const creator = build.createdBy.name || build.createdBy.email;
276
310
  lines.push(`Canceled by: ${creator}`);
277
311
  }
278
- // Show jobs summary
279
- const jobStats = this.getJobStats(build.jobs?.edges);
280
- lines.push(`Completed: ${jobStats.completed}/${jobStats.total} jobs before cancellation`);
281
312
  // Show job details if requested
282
313
  if (options?.jobs) {
283
314
  lines.push('');
@@ -323,8 +354,8 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
323
354
  lines.push(` Triggered from: ${build.triggeredFrom.pipeline?.name} #${build.triggeredFrom.number}`);
324
355
  }
325
356
  lines.push('');
326
- // Jobs section
327
- lines.push('Jobs:');
357
+ // Steps section
358
+ lines.push('Steps:');
328
359
  lines.push(this.formatJobDetails(build.jobs?.edges, { ...options, full: true }));
329
360
  // Annotations section
330
361
  if (build.annotations?.edges?.length > 0) {
@@ -340,15 +371,31 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
340
371
  const coloredIcon = this.colorizeStatusIcon(statusIcon, build.state);
341
372
  const stateFormatted = formatBuildStatus(build.state, { useSymbol: false });
342
373
  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}`;
374
+ // Get first line of commit message
375
+ const message = build.message || 'No commit message';
376
+ const firstLineMessage = message.split('\n')[0];
377
+ const truncatedMessage = firstLineMessage.length > 80 ? firstLineMessage.substring(0, 77) + '...' : firstLineMessage;
378
+ return `${coloredIcon} ${stateFormatted} ${truncatedMessage} ${SEMANTIC_COLORS.dim(`#${build.number}`)} ${SEMANTIC_COLORS.dim(duration)}`;
346
379
  }
347
380
  formatCommitInfo(build) {
348
381
  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})`;
382
+ const branch = SEMANTIC_COLORS.identifier(build.branch);
383
+ const age = this.formatAge(build.createdAt);
384
+ // Get author information
385
+ const author = build.createdBy?.name || build.createdBy?.email || 'Unknown';
386
+ // Calculate indentation to align with commit message
387
+ // Map each state to its proper indentation (icon + space + state text + space)
388
+ const indentMap = {
389
+ 'PASSED': 9, // ✓ PASSED
390
+ 'FAILED': 9, // ✗ FAILED
391
+ 'RUNNING': 10, // ⟳ RUNNING
392
+ 'BLOCKED': 10, // ◼ BLOCKED
393
+ 'CANCELED': 11, // ⊘ CANCELED
394
+ 'SCHEDULED': 12, // ⏱ SCHEDULED
395
+ 'SKIPPED': 10, // ⊙ SKIPPED
396
+ };
397
+ const indent = ' '.repeat(indentMap[build.state] || 9);
398
+ return `${indent}${author} • ${branch} • ${shortSha} • ${SEMANTIC_COLORS.dim(`Created ${age}`)}`;
352
399
  }
353
400
  formatAnnotationSummary(annotations) {
354
401
  if (!annotations || annotations.length === 0) {
@@ -383,6 +430,73 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
383
430
  }
384
431
  return lines.join('\n');
385
432
  }
433
+ formatJobSummary(jobsData, buildState) {
434
+ const jobs = jobsData?.edges;
435
+ if (!jobs || jobs.length === 0) {
436
+ return '';
437
+ }
438
+ const lines = [];
439
+ const jobStats = this.getJobStats(jobs);
440
+ // Build summary parts based on job states
441
+ const countParts = [];
442
+ if (jobStats.failed > 0)
443
+ countParts.push(SEMANTIC_COLORS.error(`${jobStats.failed} failed`));
444
+ if (jobStats.passed > 0)
445
+ countParts.push(SEMANTIC_COLORS.success(`${jobStats.passed} passed`));
446
+ if (jobStats.running > 0)
447
+ countParts.push(SEMANTIC_COLORS.info(`${jobStats.running} running`));
448
+ if (jobStats.blocked > 0)
449
+ countParts.push(SEMANTIC_COLORS.warning(`${jobStats.blocked} blocked`));
450
+ // Don't show skipped jobs
451
+ if (jobStats.canceled > 0)
452
+ countParts.push(SEMANTIC_COLORS.muted(`${jobStats.canceled} canceled`));
453
+ // Use appropriate icon based on build state
454
+ const icon = buildState === 'FAILED' ? getStateIcon('FAILED') :
455
+ buildState === 'RUNNING' ? getStateIcon('RUNNING') :
456
+ buildState === 'PASSED' ? getStateIcon('PASSED') :
457
+ buildState === 'BLOCKED' ? getStateIcon('BLOCKED') : '•';
458
+ // Check if we have partial data
459
+ const hasMorePages = jobsData?.pageInfo?.hasNextPage;
460
+ const totalCount = jobsData?.count;
461
+ if (hasMorePages) {
462
+ const showing = jobs.length;
463
+ const total = totalCount || `${showing}+`;
464
+ lines.push(`${icon} Showing ${SEMANTIC_COLORS.count(String(showing))} of ${SEMANTIC_COLORS.count(String(total))} steps: ${countParts.join(', ')}`);
465
+ lines.push(SEMANTIC_COLORS.warning('⚠️ Showing first 100 steps only (more available)'));
466
+ lines.push(SEMANTIC_COLORS.dim(' → Use --jobs to fetch all step data and see accurate statistics'));
467
+ }
468
+ else {
469
+ lines.push(`${icon} ${SEMANTIC_COLORS.count(String(jobStats.total))} step${jobStats.total > 1 ? 's' : ''}: ${countParts.join(', ')}`);
470
+ }
471
+ // For failed builds, show the specific failed job names
472
+ if (buildState === 'FAILED') {
473
+ const failedJobs = this.getFailedJobs(jobs);
474
+ const jobGroups = this.groupJobsByLabel(failedJobs);
475
+ // Show up to 3 failed job types
476
+ const displayGroups = jobGroups.slice(0, 3);
477
+ for (const group of displayGroups) {
478
+ const label = this.parseEmoji(group.label);
479
+ const icon = getStateIcon('FAILED');
480
+ // Get duration for display
481
+ const duration = group.count === 1 && group.jobs[0]?.node
482
+ ? ` ${SEMANTIC_COLORS.dim(`- ran ${this.formatJobDuration(group.jobs[0].node)}`)}`
483
+ : '';
484
+ if (group.parallelTotal > 0) {
485
+ lines.push(` ${icon} ${label} ${SEMANTIC_COLORS.dim(`(${group.stateCounts.failed || 0}/${group.parallelTotal} failed)`)}`);
486
+ }
487
+ else if (group.count > 1) {
488
+ lines.push(` ${icon} ${label} ${SEMANTIC_COLORS.dim(`(${group.stateCounts.failed || 0} failed)`)}`);
489
+ }
490
+ else {
491
+ lines.push(` ${icon} ${label}${duration}`);
492
+ }
493
+ }
494
+ if (jobGroups.length > 3) {
495
+ lines.push(` ${SEMANTIC_COLORS.muted(`...and ${jobGroups.length - 3} more`)}`);
496
+ }
497
+ }
498
+ return lines.join('\n');
499
+ }
386
500
  formatAnnotationDetails(annotations) {
387
501
  const lines = [];
388
502
  // Group annotations by style
@@ -409,7 +523,7 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
409
523
  }
410
524
  formatJobDetails(jobs, options) {
411
525
  if (!jobs || jobs.length === 0) {
412
- return 'No jobs found';
526
+ return 'No steps found';
413
527
  }
414
528
  const lines = [];
415
529
  const jobStats = this.getJobStats(jobs);
@@ -423,45 +537,83 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
423
537
  parts.push(`${getStateIcon('RUNNING')} ${jobStats.running} running`);
424
538
  if (jobStats.blocked > 0)
425
539
  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(' ')}`);
540
+ // Don't show skipped jobs in summary
541
+ lines.push(`Steps: ${parts.join(' ')}`);
429
542
  lines.push('');
430
543
  // Filter jobs based on options
431
544
  let filteredJobs = jobs;
432
545
  if (options?.failed) {
433
546
  filteredJobs = this.getFailedJobs(jobs);
434
547
  }
435
- // Group jobs by state
548
+ // Group jobs by state first
436
549
  const grouped = this.groupJobsByState(filteredJobs);
437
550
  for (const [state, stateJobs] of Object.entries(grouped)) {
438
551
  if (stateJobs.length === 0)
439
552
  continue;
440
553
  const icon = this.getJobStateIcon(state);
441
554
  const stateColored = this.colorizeJobState(state);
555
+ // Collapse parallel jobs with same label
556
+ const collapsedGroups = this.collapseParallelJobs(stateJobs);
442
557
  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}`)}`);
558
+ for (const group of collapsedGroups) {
559
+ if (group.isParallelGroup && group.jobs.length > 1) {
560
+ // Collapsed parallel group display
561
+ const label = this.parseEmoji(group.label);
562
+ const total = group.parallelTotal || group.jobs.length;
563
+ const passedCount = group.jobs.filter(j => this.isJobPassed(j.node)).length;
564
+ const failedCount = group.jobs.filter(j => this.isJobFailed(j.node)).length;
565
+ // Show summary line for parallel group
566
+ if (failedCount > 0) {
567
+ // If there are failures, show breakdown
568
+ // Apply state color to label
569
+ const coloredLabel = state === 'Failed' ? SEMANTIC_COLORS.error(label) :
570
+ state === 'Passed' ? SEMANTIC_COLORS.success(label) :
571
+ state === 'Running' ? SEMANTIC_COLORS.info(label) :
572
+ state === 'Blocked' ? SEMANTIC_COLORS.warning(label) : label;
573
+ lines.push(` ${coloredLabel} ${SEMANTIC_COLORS.dim(`(${passedCount}/${total} passed, ${failedCount} failed)`)}`);
574
+ // Show failed steps individually
575
+ const failedJobs = group.jobs.filter(j => this.isJobFailed(j.node));
576
+ for (const job of failedJobs) {
577
+ const duration = this.formatJobDuration(job.node);
578
+ const parallelInfo = job.node.parallelGroupIndex !== undefined
579
+ ? ` ${SEMANTIC_COLORS.dim(`[Parallel: ${job.node.parallelGroupIndex + 1}/${job.node.parallelGroupTotal}]`)}`
580
+ : '';
581
+ lines.push(` ${SEMANTIC_COLORS.error('↳ Failed')}: ${SEMANTIC_COLORS.dim(duration)}${parallelInfo}`);
582
+ }
457
583
  }
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}`)}`);
584
+ else {
585
+ // All passed/running/blocked - just show summary
586
+ const avgDuration = this.calculateAverageDuration(group.jobs);
587
+ // Apply state color to label
588
+ const coloredLabel = state === 'Passed' ? SEMANTIC_COLORS.success(label) :
589
+ state === 'Failed' ? SEMANTIC_COLORS.error(label) :
590
+ state === 'Running' ? SEMANTIC_COLORS.info(label) :
591
+ state === 'Blocked' ? SEMANTIC_COLORS.warning(label) : label;
592
+ lines.push(` ${coloredLabel} ${SEMANTIC_COLORS.dim(`(${total} parallel steps, avg: ${avgDuration})`)}`);
461
593
  }
462
- // Retry info
463
- if (job.node.retried) {
464
- lines.push(` ${SEMANTIC_COLORS.warning(`${getProgressIcon('RETRY')} Retried`)}`);
594
+ }
595
+ else {
596
+ // Single job or non-parallel group - display as before
597
+ const job = group.jobs[0];
598
+ const label = this.parseEmoji(job.node.label);
599
+ const duration = this.formatJobDuration(job.node);
600
+ // Apply state color to label
601
+ const coloredLabel = state === 'Passed' ? SEMANTIC_COLORS.success(label) :
602
+ state === 'Failed' ? SEMANTIC_COLORS.error(label) :
603
+ state === 'Running' ? SEMANTIC_COLORS.info(label) :
604
+ state === 'Blocked' ? SEMANTIC_COLORS.warning(label) : label;
605
+ // Add parallel info inline if present
606
+ const parallelInfo = (job.node.parallelGroupIndex !== undefined && job.node.parallelGroupTotal)
607
+ ? ` ${SEMANTIC_COLORS.dim(`[Parallel: ${job.node.parallelGroupIndex + 1}/${job.node.parallelGroupTotal}]`)}`
608
+ : '';
609
+ // Basic step line with optional parallel info
610
+ lines.push(` ${coloredLabel} ${SEMANTIC_COLORS.dim(`(${duration})`)}${parallelInfo}`);
611
+ // Show additional details if --jobs or --full and single step
612
+ if ((options?.jobs || options?.full) && !group.isParallelGroup) {
613
+ // Retry info
614
+ if (job.node.retried) {
615
+ lines.push(` ${SEMANTIC_COLORS.warning(`${getProgressIcon('RETRY')} Retried`)}`);
616
+ }
465
617
  }
466
618
  }
467
619
  }
@@ -469,67 +621,6 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
469
621
  }
470
622
  return lines.join('\n').trim();
471
623
  }
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
624
  groupJobsByLabel(jobs) {
534
625
  const groups = new Map();
535
626
  for (const job of jobs) {
@@ -688,6 +779,7 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
688
779
  running: 0,
689
780
  blocked: 0,
690
781
  skipped: 0,
782
+ canceled: 0,
691
783
  queued: 0,
692
784
  completed: 0
693
785
  };
@@ -714,7 +806,12 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
714
806
  else if (state === 'BLOCKED') {
715
807
  stats.blocked++;
716
808
  }
717
- else if (state === 'SKIPPED' || state === 'CANCELED') {
809
+ else if (state === 'CANCELED' || state === 'CANCELLED') {
810
+ stats.canceled++;
811
+ stats.completed++;
812
+ }
813
+ else if (state === 'SKIPPED' || state === 'BROKEN') {
814
+ // BROKEN jobs are functionally skipped - they don't run due to conditions not matching
718
815
  stats.skipped++;
719
816
  stats.completed++;
720
817
  }
@@ -736,7 +833,7 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
736
833
  stats.passed++;
737
834
  stats.completed++;
738
835
  }
739
- else if (state === 'FAILED' || state === 'BROKEN' || job.node.passed === false) {
836
+ else if (state === 'FAILED' || job.node.passed === false) {
740
837
  stats.failed++;
741
838
  stats.completed++;
742
839
  }
@@ -748,14 +845,22 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
748
845
  return [];
749
846
  return jobs.filter(job => {
750
847
  const state = job.node.state?.toUpperCase();
848
+ // BROKEN jobs are skipped/not run, not failed
849
+ if (state === 'BROKEN' || state === 'SKIPPED') {
850
+ return false;
851
+ }
751
852
  // If we have an exit status, use that as the source of truth
752
853
  // Note: exitStatus comes as a string from Buildkite API
753
854
  if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) {
754
855
  const exitCode = parseInt(job.node.exitStatus, 10);
755
856
  return exitCode !== 0;
756
857
  }
757
- // Otherwise fall back to state
758
- return state === 'FAILED' || state === 'BROKEN' || job.node.passed === false;
858
+ // For FINISHED jobs, check the passed field
859
+ if (state === 'FINISHED') {
860
+ return job.node.passed === false;
861
+ }
862
+ // Otherwise check if explicitly failed
863
+ return state === 'FAILED';
759
864
  });
760
865
  }
761
866
  getRunningJobs(jobs) {
@@ -773,8 +878,8 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
773
878
  'Failed': [],
774
879
  'Passed': [],
775
880
  'Running': [],
776
- 'Blocked': [],
777
- 'Skipped': []
881
+ 'Blocked': []
882
+ // Don't include Skipped - we don't display them
778
883
  };
779
884
  if (!jobs)
780
885
  return grouped;
@@ -797,8 +902,9 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
797
902
  else if (state === 'BLOCKED') {
798
903
  grouped['Blocked'].push(job);
799
904
  }
800
- else if (state === 'SKIPPED' || state === 'CANCELED') {
801
- grouped['Skipped'].push(job);
905
+ else if (state === 'SKIPPED' || state === 'CANCELED' || state === 'BROKEN') {
906
+ // Don't display skipped/broken/canceled jobs - they're not shown in Buildkite UI
907
+ // Skip these entirely
802
908
  }
803
909
  else if (state === 'FINISHED' || state === 'COMPLETED') {
804
910
  // For finished jobs without exit status, check passed field
@@ -812,12 +918,88 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
812
918
  else if (state === 'PASSED' || job.node.passed === true) {
813
919
  grouped['Passed'].push(job);
814
920
  }
815
- else if (state === 'FAILED' || state === 'BROKEN' || job.node.passed === false) {
921
+ else if (state === 'FAILED') {
816
922
  grouped['Failed'].push(job);
817
923
  }
818
924
  }
819
925
  return grouped;
820
926
  }
927
+ collapseParallelJobs(jobs) {
928
+ const groups = new Map();
929
+ // Group jobs by label
930
+ for (const job of jobs) {
931
+ const label = job.node.label || 'Unnamed';
932
+ if (!groups.has(label)) {
933
+ groups.set(label, []);
934
+ }
935
+ groups.get(label).push(job);
936
+ }
937
+ // Convert to array and determine if each group is a parallel group
938
+ const result = [];
939
+ for (const [label, groupJobs] of groups.entries()) {
940
+ // Check if this is a parallel group (multiple jobs with same label and parallel info)
941
+ const hasParallelInfo = groupJobs.some(j => j.node.parallelGroupIndex !== undefined && j.node.parallelGroupTotal !== undefined);
942
+ const isParallelGroup = hasParallelInfo && groupJobs.length > 1;
943
+ // Get the total from the first job if available
944
+ const parallelTotal = groupJobs[0]?.node?.parallelGroupTotal;
945
+ result.push({
946
+ label,
947
+ jobs: groupJobs,
948
+ isParallelGroup,
949
+ parallelTotal
950
+ });
951
+ }
952
+ return result;
953
+ }
954
+ isJobPassed(job) {
955
+ const state = job.state?.toUpperCase();
956
+ if (job.exitStatus !== null && job.exitStatus !== undefined) {
957
+ return parseInt(job.exitStatus, 10) === 0;
958
+ }
959
+ if (state === 'PASSED')
960
+ return true;
961
+ if (state === 'FINISHED' || state === 'COMPLETED') {
962
+ return job.passed === true;
963
+ }
964
+ return job.passed === true;
965
+ }
966
+ isJobFailed(job) {
967
+ const state = job.state?.toUpperCase();
968
+ if (job.exitStatus !== null && job.exitStatus !== undefined) {
969
+ return parseInt(job.exitStatus, 10) !== 0;
970
+ }
971
+ if (state === 'FAILED')
972
+ return true;
973
+ if (state === 'FINISHED' || state === 'COMPLETED') {
974
+ return job.passed === false;
975
+ }
976
+ return false;
977
+ }
978
+ calculateAverageDuration(jobs) {
979
+ const durationsMs = jobs
980
+ .filter(j => j.node.startedAt && j.node.finishedAt)
981
+ .map(j => {
982
+ const start = new Date(j.node.startedAt).getTime();
983
+ const end = new Date(j.node.finishedAt).getTime();
984
+ return end - start;
985
+ });
986
+ if (durationsMs.length === 0) {
987
+ return 'unknown';
988
+ }
989
+ const avgMs = durationsMs.reduce((a, b) => a + b, 0) / durationsMs.length;
990
+ const avgSeconds = Math.floor(avgMs / 1000);
991
+ if (avgSeconds < 60) {
992
+ return `${avgSeconds}s`;
993
+ }
994
+ const minutes = Math.floor(avgSeconds / 60);
995
+ const seconds = avgSeconds % 60;
996
+ if (minutes < 60) {
997
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
998
+ }
999
+ const hours = Math.floor(minutes / 60);
1000
+ const remainingMinutes = minutes % 60;
1001
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
1002
+ }
821
1003
  countAnnotationsByStyle(annotations) {
822
1004
  const counts = {
823
1005
  ERROR: 0,