bktide 1.0.1755559112 → 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.
Files changed (32) hide show
  1. package/README.md +42 -0
  2. package/dist/commands/ShowBuild.js +31 -5
  3. package/dist/commands/ShowBuild.js.map +1 -1
  4. package/dist/formatters/build-detail/PlainTextFormatter.js +533 -180
  5. package/dist/formatters/build-detail/PlainTextFormatter.js.map +1 -1
  6. package/dist/formatters/builds/PlainTextFormatter.js +4 -2
  7. package/dist/formatters/builds/PlainTextFormatter.js.map +1 -1
  8. package/dist/formatters/pipelines/PlainTextFormatter.js +3 -6
  9. package/dist/formatters/pipelines/PlainTextFormatter.js.map +1 -1
  10. package/dist/graphql/fragments/index.js +3 -0
  11. package/dist/graphql/fragments/index.js.map +1 -0
  12. package/dist/graphql/fragments/jobs.js +112 -0
  13. package/dist/graphql/fragments/jobs.js.map +1 -0
  14. package/dist/graphql/queries.js +35 -53
  15. package/dist/graphql/queries.js.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/scripts/extract-data-patterns.js +118 -0
  19. package/dist/scripts/extract-data-patterns.js.map +1 -0
  20. package/dist/services/BuildkiteClient.js +109 -32
  21. package/dist/services/BuildkiteClient.js.map +1 -1
  22. package/dist/services/BuildkiteRestClient.js +8 -7
  23. package/dist/services/BuildkiteRestClient.js.map +1 -1
  24. package/dist/test-helpers/DataProfiler.js +307 -0
  25. package/dist/test-helpers/DataProfiler.js.map +1 -0
  26. package/dist/test-helpers/PatternMockGenerator.js +590 -0
  27. package/dist/test-helpers/PatternMockGenerator.js.map +1 -0
  28. package/dist/ui/theme.js +193 -8
  29. package/dist/ui/theme.js.map +1 -1
  30. package/dist/utils/terminal-links.js +165 -0
  31. package/dist/utils/terminal-links.js.map +1 -0
  32. package/package.json +19 -3
@@ -1,7 +1,7 @@
1
1
  import { BaseBuildDetailFormatter } from './Formatter.js';
2
2
  import { formatDistanceToNow } from 'date-fns';
3
3
  import { htmlToText } from 'html-to-text';
4
- import { formatEmptyState, formatError, SEMANTIC_COLORS, formatBuildStatus } from '../../ui/theme.js';
4
+ import { formatEmptyState, formatError, SEMANTIC_COLORS, formatBuildStatus, formatTips, TipStyle, getStateIcon, getAnnotationIcon, getProgressIcon, BUILD_STATUS_THEME } from '../../ui/theme.js';
5
5
  // Standard emoji mappings only
6
6
  // Only map universally recognized emoji codes, not Buildkite-specific ones
7
7
  const STANDARD_EMOJI = {
@@ -127,28 +127,40 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
127
127
  return formatError(options?.errorMessage || 'Unknown error');
128
128
  }
129
129
  formatSummaryLine(build) {
130
- const status = this.getStatusIcon(build.state);
130
+ const statusIcon = this.getStatusIcon(build.state);
131
+ const coloredIcon = this.colorizeStatusIcon(statusIcon, build.state);
131
132
  const duration = this.formatDuration(build);
132
133
  const age = this.formatAge(build.createdAt);
133
- return `${status} #${build.number} ${build.state.toLowerCase()} • ${duration} ${build.branch} • ${age}`;
134
+ const stateFormatted = formatBuildStatus(build.state, { useSymbol: false });
135
+ const branch = SEMANTIC_COLORS.identifier(build.branch);
136
+ return `${coloredIcon} ${SEMANTIC_COLORS.label(`#${build.number}`)} ${stateFormatted} • ${duration} • ${branch} • ${age}`;
134
137
  }
135
138
  formatPassedBuild(build, options) {
136
139
  const lines = [];
137
140
  // Header line
138
141
  lines.push(this.formatHeader(build));
139
142
  lines.push(this.formatCommitInfo(build));
143
+ lines.push(''); // Blank line after commit info
140
144
  // Show annotations summary if present
141
145
  if (build.annotations?.edges?.length > 0) {
142
- lines.push('');
143
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));
144
154
  if (!options?.annotations) {
145
- lines.push(SEMANTIC_COLORS.dim(`→ bin/bktide build ${build.number} --annotations # view annotations`));
155
+ lines.push('');
156
+ const tips = formatTips(['Use --annotations to view annotation details'], TipStyle.GROUPED);
157
+ lines.push(tips);
146
158
  }
147
159
  }
148
160
  // Show annotations detail if requested
149
161
  if (options?.annotations) {
150
162
  lines.push('');
151
- lines.push(this.formatAnnotationDetails(build.annotations.edges, options));
163
+ lines.push(this.formatAnnotationDetails(build.annotations.edges));
152
164
  }
153
165
  return lines.join('\n');
154
166
  }
@@ -158,15 +170,18 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
158
170
  lines.push(this.formatHeader(build));
159
171
  lines.push(this.formatCommitInfo(build));
160
172
  lines.push('');
161
- // Failed jobs summary
162
- const failedJobs = this.getFailedJobs(build.jobs?.edges);
163
- if (failedJobs.length > 0) {
164
- lines.push(this.formatFailedJobsSummary(failedJobs));
165
- }
166
- // Annotation summary
173
+ const allHints = [];
174
+ // Annotation summary (first, as it appears first in UI)
167
175
  if (build.annotations?.edges?.length > 0) {
168
176
  lines.push(this.formatAnnotationSummary(build.annotations.edges));
169
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
+ }
170
185
  // Show detailed job info if requested
171
186
  if (options?.jobs || options?.failed) {
172
187
  lines.push('');
@@ -175,15 +190,24 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
175
190
  // Show annotations detail if requested
176
191
  if (options?.annotations) {
177
192
  lines.push('');
178
- lines.push(this.formatAnnotationDetails(build.annotations.edges, options));
193
+ lines.push(this.formatAnnotationDetails(build.annotations.edges));
179
194
  }
180
- // Hints for more info (no Tips label)
195
+ // Collect all hints for more info
196
+ const failedJobs = this.getFailedJobs(build.jobs?.edges);
181
197
  if (!options?.failed && failedJobs.length > 0) {
182
- lines.push('');
183
- lines.push(SEMANTIC_COLORS.dim(`→ bin/bktide build ${build.number} --failed # show failure details`));
198
+ allHints.push('Use --failed to show failure details');
184
199
  }
185
200
  if (!options?.annotations && build.annotations?.edges?.length > 0) {
186
- lines.push(SEMANTIC_COLORS.dim(`→ bin/bktide build ${build.number} --annotations # view annotations`));
201
+ allHints.push('Use --annotations to view annotation details');
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
+ }
207
+ // Display all hints together
208
+ if (allHints.length > 0) {
209
+ lines.push('');
210
+ lines.push(formatTips(allHints, TipStyle.GROUPED));
187
211
  }
188
212
  return lines.join('\n');
189
213
  }
@@ -193,20 +217,38 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
193
217
  lines.push(this.formatHeader(build));
194
218
  lines.push(this.formatCommitInfo(build));
195
219
  lines.push('');
196
- // Progress information
197
- const jobStats = this.getJobStats(build.jobs?.edges);
198
- 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
+ }
199
231
  // Show running jobs
200
232
  const runningJobs = this.getRunningJobs(build.jobs?.edges);
201
233
  if (runningJobs.length > 0) {
202
234
  const labels = runningJobs.map(j => this.parseEmoji(j.node.label)).join(', ');
203
235
  lines.push(`${SEMANTIC_COLORS.info('Running')}: ${labels}`);
204
236
  }
237
+ // Annotation summary
238
+ if (build.annotations?.edges?.length > 0) {
239
+ lines.push('');
240
+ lines.push(this.formatAnnotationSummary(build.annotations.edges));
241
+ }
205
242
  // Show job details if requested
206
243
  if (options?.jobs) {
207
244
  lines.push('');
208
245
  lines.push(this.formatJobDetails(build.jobs?.edges, options));
209
246
  }
247
+ // Show annotations detail if requested
248
+ if (options?.annotations) {
249
+ lines.push('');
250
+ lines.push(this.formatAnnotationDetails(build.annotations.edges));
251
+ }
210
252
  return lines.join('\n');
211
253
  }
212
254
  formatBlockedBuild(build, options) {
@@ -215,21 +257,33 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
215
257
  lines.push(this.formatHeader(build));
216
258
  lines.push(this.formatCommitInfo(build));
217
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
+ }
218
271
  // Blocked information
219
272
  const blockedJobs = this.getBlockedJobs(build.jobs?.edges);
220
273
  if (blockedJobs.length > 0) {
221
- lines.push(`🚫 Blocked: "${blockedJobs[0].node.label}" (manual unblock required)`);
222
- }
223
- // Show jobs summary
224
- const jobStats = this.getJobStats(build.jobs?.edges);
225
- if (jobStats.completed > 0) {
226
- lines.push(`✅ ${jobStats.completed} jobs passed before block`);
274
+ lines.push('');
275
+ lines.push(`${getProgressIcon('BLOCKED_MESSAGE')} Blocked: "${blockedJobs[0].node.label}" (manual unblock required)`);
227
276
  }
228
277
  // Show job details if requested
229
278
  if (options?.jobs) {
230
279
  lines.push('');
231
280
  lines.push(this.formatJobDetails(build.jobs?.edges, options));
232
281
  }
282
+ // Show annotations detail if requested
283
+ if (options?.annotations) {
284
+ lines.push('');
285
+ lines.push(this.formatAnnotationDetails(build.annotations.edges));
286
+ }
233
287
  return lines.join('\n');
234
288
  }
235
289
  formatCanceledBuild(build, options) {
@@ -238,14 +292,23 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
238
292
  lines.push(this.formatHeader(build));
239
293
  lines.push(this.formatCommitInfo(build));
240
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
+ }
241
306
  // Canceled information
242
307
  if (build.createdBy) {
308
+ lines.push('');
243
309
  const creator = build.createdBy.name || build.createdBy.email;
244
310
  lines.push(`Canceled by: ${creator}`);
245
311
  }
246
- // Show jobs summary
247
- const jobStats = this.getJobStats(build.jobs?.edges);
248
- lines.push(`Completed: ${jobStats.completed}/${jobStats.total} jobs before cancellation`);
249
312
  // Show job details if requested
250
313
  if (options?.jobs) {
251
314
  lines.push('');
@@ -268,62 +331,185 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
268
331
  lines.push(` Organization: ${build.organization?.name || 'Unknown'}`);
269
332
  lines.push(` Pipeline: ${build.pipeline?.name || 'Unknown'}`);
270
333
  if (build.pullRequest) {
271
- lines.push(` Pull Request: #${build.pullRequest.number}`);
334
+ // Try to construct PR URL from repository URL
335
+ const repoUrl = build.pipeline?.repository?.url;
336
+ if (repoUrl && repoUrl.includes('github.com')) {
337
+ // Extract owner/repo from various GitHub URL formats
338
+ const match = repoUrl.match(/github\.com[/:]([\w-]+)\/([\w-]+)/);
339
+ if (match && build.pullRequest.id) {
340
+ // Extract PR number from GraphQL ID if possible
341
+ // GitHub PR IDs often contain the number
342
+ const prUrl = `https://github.com/${match[1]}/${match[2]}/pull/${build.pullRequest.id}`;
343
+ lines.push(` Pull Request: ${SEMANTIC_COLORS.url(prUrl)}`);
344
+ }
345
+ else {
346
+ lines.push(` Pull Request: ${build.pullRequest.id}`);
347
+ }
348
+ }
349
+ else {
350
+ lines.push(` Pull Request: ${build.pullRequest.id}`);
351
+ }
272
352
  }
273
353
  if (build.triggeredFrom) {
274
354
  lines.push(` Triggered from: ${build.triggeredFrom.pipeline?.name} #${build.triggeredFrom.number}`);
275
355
  }
276
356
  lines.push('');
277
- // Jobs section
278
- lines.push('Jobs:');
357
+ // Steps section
358
+ lines.push('Steps:');
279
359
  lines.push(this.formatJobDetails(build.jobs?.edges, { ...options, full: true }));
280
360
  // Annotations section
281
361
  if (build.annotations?.edges?.length > 0) {
282
362
  lines.push('');
283
363
  lines.push('Annotations:');
284
- lines.push(this.formatAnnotationDetails(build.annotations.edges, { ...options, annotationsFull: true }));
364
+ lines.push(this.formatAnnotationDetails(build.annotations.edges));
285
365
  }
286
366
  return lines.join('\n');
287
367
  }
288
368
  formatHeader(build) {
289
- const status = this.getStatusIcon(build.state);
369
+ const statusIcon = this.getStatusIcon(build.state);
370
+ // Apply appropriate color to the icon based on the state
371
+ const coloredIcon = this.colorizeStatusIcon(statusIcon, build.state);
290
372
  const stateFormatted = formatBuildStatus(build.state, { useSymbol: false });
291
373
  const duration = this.formatDuration(build);
292
- const age = this.formatAge(build.createdAt);
293
- const branch = SEMANTIC_COLORS.identifier(build.branch);
294
- return `${status} ${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)}`;
295
379
  }
296
380
  formatCommitInfo(build) {
297
381
  const shortSha = build.commit ? build.commit.substring(0, 7) : 'unknown';
298
- const message = build.message || 'No commit message';
299
- const truncatedMessage = message.length > 60 ? message.substring(0, 57) + '...' : message;
300
- 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}`)}`;
301
399
  }
302
400
  formatAnnotationSummary(annotations) {
401
+ if (!annotations || annotations.length === 0) {
402
+ return '';
403
+ }
404
+ const lines = [];
405
+ const total = annotations.length;
406
+ // Header with count
303
407
  const counts = this.countAnnotationsByStyle(annotations);
304
- const parts = [];
408
+ const countParts = [];
305
409
  if (counts.ERROR > 0)
306
- parts.push(SEMANTIC_COLORS.error(`${counts.ERROR} error${counts.ERROR > 1 ? 's' : ''}`));
410
+ countParts.push(SEMANTIC_COLORS.error(`${counts.ERROR} error${counts.ERROR > 1 ? 's' : ''}`));
307
411
  if (counts.WARNING > 0)
308
- parts.push(SEMANTIC_COLORS.warning(`${counts.WARNING} warning${counts.WARNING > 1 ? 's' : ''}`));
412
+ countParts.push(SEMANTIC_COLORS.warning(`${counts.WARNING} warning${counts.WARNING > 1 ? 's' : ''}`));
309
413
  if (counts.INFO > 0)
310
- parts.push(SEMANTIC_COLORS.info(`${counts.INFO} info`));
414
+ countParts.push(SEMANTIC_COLORS.info(`${counts.INFO} info`));
311
415
  if (counts.SUCCESS > 0)
312
- parts.push(SEMANTIC_COLORS.success(`${counts.SUCCESS} success`));
313
- const total = annotations.length;
314
- return `📝 ${SEMANTIC_COLORS.count(String(total))} annotation${total > 1 ? 's' : ''}: ${parts.join(', ')}`;
416
+ countParts.push(SEMANTIC_COLORS.success(`${counts.SUCCESS} success`));
417
+ lines.push(`${getAnnotationIcon('DEFAULT')} ${SEMANTIC_COLORS.count(String(total))} annotation${total > 1 ? 's' : ''}: ${countParts.join(', ')}`);
418
+ // List each annotation with style and context
419
+ const grouped = this.groupAnnotationsByStyle(annotations);
420
+ const styleOrder = ['ERROR', 'WARNING', 'INFO', 'SUCCESS'];
421
+ for (const style of styleOrder) {
422
+ if (grouped[style]) {
423
+ for (const annotation of grouped[style]) {
424
+ const icon = this.getAnnotationIcon(style);
425
+ const context = annotation.node.context || 'default';
426
+ const styleColored = this.colorizeAnnotationStyle(style);
427
+ lines.push(` ${icon} ${styleColored}: ${context}`);
428
+ }
429
+ }
430
+ }
431
+ return lines.join('\n');
315
432
  }
316
- formatAnnotationDetails(annotations, options) {
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
+ }
500
+ formatAnnotationDetails(annotations) {
317
501
  const lines = [];
318
502
  // Group annotations by style
319
503
  const grouped = this.groupAnnotationsByStyle(annotations);
320
- for (const [style, items] of Object.entries(grouped)) {
321
- for (const annotation of items) {
322
- const icon = this.getAnnotationIcon(style);
323
- const context = annotation.node.context || 'default';
324
- if (options?.annotationsFull) {
325
- // Full content
326
- lines.push(`${icon} ${style} [${context}]:`);
504
+ const styleOrder = ['ERROR', 'WARNING', 'INFO', 'SUCCESS'];
505
+ for (const style of styleOrder) {
506
+ if (grouped[style]) {
507
+ for (const annotation of grouped[style]) {
508
+ const icon = this.getAnnotationIcon(style);
509
+ const context = annotation.node.context || 'default';
510
+ const styleColored = this.colorizeAnnotationStyle(style);
511
+ // When showing annotation details, always show the body text
512
+ lines.push(`${icon} ${styleColored}: ${context}`);
327
513
  const body = htmlToText(annotation.node.body?.html || '', {
328
514
  wordwrap: 80,
329
515
  preserveNewlines: true
@@ -331,118 +517,123 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
331
517
  lines.push(body.split('\n').map(l => ` ${l}`).join('\n'));
332
518
  lines.push('');
333
519
  }
334
- else {
335
- // Summary only
336
- lines.push(`${icon} ${style} [${context}]`);
337
- }
338
520
  }
339
521
  }
340
- return lines.join('\n');
522
+ return lines.join('\n').trim();
341
523
  }
342
524
  formatJobDetails(jobs, options) {
343
525
  if (!jobs || jobs.length === 0) {
344
- return 'No jobs found';
526
+ return 'No steps found';
345
527
  }
346
528
  const lines = [];
347
529
  const jobStats = this.getJobStats(jobs);
348
530
  // Summary line
349
531
  const parts = [];
350
532
  if (jobStats.passed > 0)
351
- parts.push(`✅ ${jobStats.passed} passed`);
533
+ parts.push(`${getStateIcon('PASSED')} ${jobStats.passed} passed`);
352
534
  if (jobStats.failed > 0)
353
- parts.push(`❌ ${jobStats.failed} failed`);
535
+ parts.push(`${getStateIcon('FAILED')} ${jobStats.failed} failed`);
354
536
  if (jobStats.running > 0)
355
- parts.push(`🔄 ${jobStats.running} running`);
537
+ parts.push(`${getStateIcon('RUNNING')} ${jobStats.running} running`);
356
538
  if (jobStats.blocked > 0)
357
- parts.push(`⏸️ ${jobStats.blocked} blocked`);
358
- if (jobStats.skipped > 0)
359
- parts.push(`⏭️ ${jobStats.skipped} skipped`);
360
- lines.push(`Jobs: ${parts.join(' ')}`);
539
+ parts.push(`${getStateIcon('BLOCKED')} ${jobStats.blocked} blocked`);
540
+ // Don't show skipped jobs in summary
541
+ lines.push(`Steps: ${parts.join(' ')}`);
361
542
  lines.push('');
362
543
  // Filter jobs based on options
363
544
  let filteredJobs = jobs;
364
545
  if (options?.failed) {
365
546
  filteredJobs = this.getFailedJobs(jobs);
366
547
  }
367
- // Group jobs by state
548
+ // Group jobs by state first
368
549
  const grouped = this.groupJobsByState(filteredJobs);
369
550
  for (const [state, stateJobs] of Object.entries(grouped)) {
370
551
  if (stateJobs.length === 0)
371
552
  continue;
372
553
  const icon = this.getJobStateIcon(state);
373
554
  const stateColored = this.colorizeJobState(state);
555
+ // Collapse parallel jobs with same label
556
+ const collapsedGroups = this.collapseParallelJobs(stateJobs);
374
557
  lines.push(`${icon} ${stateColored} (${SEMANTIC_COLORS.count(String(stateJobs.length))}):`);
375
- for (const job of stateJobs) {
376
- const label = this.parseEmoji(job.node.label);
377
- const duration = this.formatJobDuration(job.node);
378
- const exitCode = job.node.exitStatus ? `, exit ${job.node.exitStatus}` : '';
379
- lines.push(` ${label} (${duration}${exitCode})`);
380
- if (options?.full && job.node.agent) {
381
- lines.push(` ${SEMANTIC_COLORS.dim(`Agent: ${job.node.agent.name || job.node.agent.hostname}`)}`);
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
+ }
583
+ }
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})`)}`);
593
+ }
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
+ }
617
+ }
382
618
  }
383
619
  }
384
620
  lines.push('');
385
621
  }
386
622
  return lines.join('\n').trim();
387
623
  }
388
- formatFailedJobsSummary(failedJobs) {
389
- const lines = [];
390
- // Group identical jobs by label
391
- const jobGroups = this.groupJobsByLabel(failedJobs);
392
- // Show first 10 unique job types
393
- const displayGroups = jobGroups.slice(0, 10);
394
- for (const group of displayGroups) {
395
- const label = this.parseEmoji(group.label);
396
- if (group.count === 1) {
397
- const duration = this.formatJobDuration(group.jobs[0].node);
398
- lines.push(` ${SEMANTIC_COLORS.error('Failed')}: ${label} - ran ${duration}`);
399
- }
400
- else {
401
- // Multiple jobs with same label - show detailed breakdown
402
- const statusParts = [];
403
- if (group.stateCounts.failed > 0) {
404
- statusParts.push(`${group.stateCounts.failed} failed`);
405
- }
406
- if (group.stateCounts.broken > 0) {
407
- statusParts.push(`${group.stateCounts.broken} broken`);
408
- }
409
- if (group.stateCounts.notStarted > 0) {
410
- statusParts.push(`${group.stateCounts.notStarted} not started`);
411
- }
412
- if (group.stateCounts.passed > 0) {
413
- statusParts.push(`${group.stateCounts.passed} passed`);
414
- }
415
- if (group.stateCounts.other > 0) {
416
- statusParts.push(`${group.stateCounts.other} other`);
417
- }
418
- // Add exit codes if available
419
- if (group.exitCodes.length > 0) {
420
- const exitCodeStr = group.exitCodes.length === 1
421
- ? `exit ${group.exitCodes[0]}`
422
- : `exits: ${group.exitCodes.join(', ')}`;
423
- statusParts.push(exitCodeStr);
424
- }
425
- const statusInfo = statusParts.join(', ') || 'various states';
426
- lines.push(` ${SEMANTIC_COLORS.error('Failed')}: ${label} (${SEMANTIC_COLORS.count(String(group.count))} jobs: ${statusInfo})`);
427
- }
428
- }
429
- // Add summary if there are more job types
430
- const remaining = jobGroups.length - displayGroups.length;
431
- if (remaining > 0) {
432
- lines.push(` ${SEMANTIC_COLORS.muted(`...and ${remaining} more job types`)}`);
433
- }
434
- return lines.join('\n');
435
- }
436
624
  groupJobsByLabel(jobs) {
437
625
  const groups = new Map();
438
626
  for (const job of jobs) {
439
- const label = job.node.label || 'Unnamed job';
440
- if (!groups.has(label)) {
441
- groups.set(label, {
442
- label,
627
+ const fullLabel = job.node.label || 'Unnamed job';
628
+ // Strip parallel job index from label for grouping
629
+ // e.g., "deposit_and_filing_schedule_calculator rspec (1/22)" -> "deposit_and_filing_schedule_calculator rspec"
630
+ const baseLabel = fullLabel.replace(/\s*\(\d+\/\d+\)\s*$/, '').trim();
631
+ if (!groups.has(baseLabel)) {
632
+ groups.set(baseLabel, {
633
+ label: baseLabel,
443
634
  count: 0,
444
635
  jobs: [],
445
- exitCodes: new Set(),
636
+ parallelTotal: 0,
446
637
  stateCounts: {
447
638
  failed: 0,
448
639
  broken: 0,
@@ -452,18 +643,41 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
452
643
  }
453
644
  });
454
645
  }
455
- const group = groups.get(label);
646
+ const group = groups.get(baseLabel);
456
647
  group.count++;
457
648
  group.jobs.push(job);
458
- // Track exit codes
459
- if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) {
460
- group.exitCodes.add(job.node.exitStatus);
649
+ // Track the maximum parallel total for this job group
650
+ if (job.node.parallelGroupTotal && job.node.parallelGroupTotal > group.parallelTotal) {
651
+ group.parallelTotal = job.node.parallelGroupTotal;
461
652
  }
462
653
  // Count by state
463
654
  const state = job.node.state?.toUpperCase();
464
- if (!job.node.startedAt) {
655
+ // Use exit status as source of truth when available
656
+ // Note: exitStatus comes as a string from Buildkite API
657
+ if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) {
658
+ const exitCode = parseInt(job.node.exitStatus, 10);
659
+ if (exitCode === 0) {
660
+ group.stateCounts.passed++;
661
+ }
662
+ else {
663
+ group.stateCounts.failed++;
664
+ }
665
+ }
666
+ else if (!job.node.startedAt) {
465
667
  group.stateCounts.notStarted++;
466
668
  }
669
+ else if (state === 'FINISHED' || state === 'COMPLETED') {
670
+ // For finished jobs without exit status, check passed field
671
+ if (job.node.passed === true) {
672
+ group.stateCounts.passed++;
673
+ }
674
+ else if (job.node.passed === false) {
675
+ group.stateCounts.failed++;
676
+ }
677
+ else {
678
+ group.stateCounts.other++;
679
+ }
680
+ }
467
681
  else if (state === 'FAILED') {
468
682
  group.stateCounts.failed++;
469
683
  }
@@ -479,7 +693,6 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
479
693
  }
480
694
  // Convert to array and sort by count (most failures first)
481
695
  return Array.from(groups.values())
482
- .map(g => ({ ...g, exitCodes: Array.from(g.exitCodes) }))
483
696
  .sort((a, b) => b.count - a.count);
484
697
  }
485
698
  formatDuration(build) {
@@ -528,37 +741,35 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
528
741
  }
529
742
  }
530
743
  getStatusIcon(state) {
531
- const icons = {
532
- 'PASSED': '✅',
533
- 'FAILED': '❌',
534
- 'RUNNING': '🔄',
535
- 'BLOCKED': '⏸️',
536
- 'CANCELED': '🚫',
537
- 'SCHEDULED': '📅',
538
- 'SKIPPED': '⏭️'
539
- };
540
- return icons[state] || '❓';
744
+ return getStateIcon(state);
541
745
  }
542
746
  getJobStateIcon(state) {
543
- const icons = {
544
- 'passed': '✅',
545
- 'failed': '❌',
546
- 'running': '🔄',
547
- 'blocked': '⏸️',
548
- 'canceled': '🚫',
549
- 'scheduled': '📅',
550
- 'skipped': '⏭️'
551
- };
552
- return icons[state.toLowerCase()] || '❓';
747
+ return getStateIcon(state);
553
748
  }
554
749
  getAnnotationIcon(style) {
555
- const icons = {
556
- 'ERROR': '❌',
557
- 'WARNING': '⚠️',
558
- 'INFO': 'ℹ️',
559
- 'SUCCESS': '✅'
560
- };
561
- return icons[style.toUpperCase()] || '📝';
750
+ return getAnnotationIcon(style);
751
+ }
752
+ colorizeAnnotationStyle(style) {
753
+ switch (style.toUpperCase()) {
754
+ case 'ERROR':
755
+ return SEMANTIC_COLORS.error(style.toLowerCase());
756
+ case 'WARNING':
757
+ return SEMANTIC_COLORS.warning(style.toLowerCase());
758
+ case 'INFO':
759
+ return SEMANTIC_COLORS.info(style.toLowerCase());
760
+ case 'SUCCESS':
761
+ return SEMANTIC_COLORS.success(style.toLowerCase());
762
+ default:
763
+ return style.toLowerCase();
764
+ }
765
+ }
766
+ colorizeStatusIcon(icon, state) {
767
+ const upperState = state.toUpperCase();
768
+ const theme = BUILD_STATUS_THEME[upperState];
769
+ if (!theme) {
770
+ return SEMANTIC_COLORS.muted(icon);
771
+ }
772
+ return theme.color(icon);
562
773
  }
563
774
  getJobStats(jobs) {
564
775
  const stats = {
@@ -568,6 +779,7 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
568
779
  running: 0,
569
780
  blocked: 0,
570
781
  skipped: 0,
782
+ canceled: 0,
571
783
  queued: 0,
572
784
  completed: 0
573
785
  };
@@ -575,13 +787,18 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
575
787
  return stats;
576
788
  for (const job of jobs) {
577
789
  const state = job.node.state?.toUpperCase() || '';
578
- if (state === 'PASSED' || (job.node.passed === true && state !== 'BROKEN')) {
579
- stats.passed++;
580
- stats.completed++;
581
- }
582
- else if (state === 'FAILED' || state === 'BROKEN' || (job.node.exitStatus && job.node.exitStatus !== 0) || job.node.passed === false) {
583
- stats.failed++;
584
- stats.completed++;
790
+ // If we have an exit status, use that as the source of truth
791
+ // Note: exitStatus comes as a string from Buildkite API
792
+ if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) {
793
+ const exitCode = parseInt(job.node.exitStatus, 10);
794
+ if (exitCode === 0) {
795
+ stats.passed++;
796
+ stats.completed++;
797
+ }
798
+ else {
799
+ stats.failed++;
800
+ stats.completed++;
801
+ }
585
802
  }
586
803
  else if (state === 'RUNNING') {
587
804
  stats.running++;
@@ -589,13 +806,37 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
589
806
  else if (state === 'BLOCKED') {
590
807
  stats.blocked++;
591
808
  }
592
- 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
593
815
  stats.skipped++;
594
816
  stats.completed++;
595
817
  }
596
818
  else if (state === 'SCHEDULED' || state === 'ASSIGNED') {
597
819
  stats.queued++;
598
820
  }
821
+ else if (state === 'FINISHED' || state === 'COMPLETED') {
822
+ // For finished jobs without exit status, check passed field
823
+ if (job.node.passed === true) {
824
+ stats.passed++;
825
+ stats.completed++;
826
+ }
827
+ else if (job.node.passed === false) {
828
+ stats.failed++;
829
+ stats.completed++;
830
+ }
831
+ }
832
+ else if (state === 'PASSED' || job.node.passed === true) {
833
+ stats.passed++;
834
+ stats.completed++;
835
+ }
836
+ else if (state === 'FAILED' || job.node.passed === false) {
837
+ stats.failed++;
838
+ stats.completed++;
839
+ }
599
840
  }
600
841
  return stats;
601
842
  }
@@ -604,7 +845,22 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
604
845
  return [];
605
846
  return jobs.filter(job => {
606
847
  const state = job.node.state?.toUpperCase();
607
- return state === 'FAILED' || state === 'BROKEN' || (job.node.exitStatus && job.node.exitStatus !== 0) || job.node.passed === false;
848
+ // BROKEN jobs are skipped/not run, not failed
849
+ if (state === 'BROKEN' || state === 'SKIPPED') {
850
+ return false;
851
+ }
852
+ // If we have an exit status, use that as the source of truth
853
+ // Note: exitStatus comes as a string from Buildkite API
854
+ if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) {
855
+ const exitCode = parseInt(job.node.exitStatus, 10);
856
+ return exitCode !== 0;
857
+ }
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';
608
864
  });
609
865
  }
610
866
  getRunningJobs(jobs) {
@@ -622,18 +878,23 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
622
878
  'Failed': [],
623
879
  'Passed': [],
624
880
  'Running': [],
625
- 'Blocked': [],
626
- 'Skipped': []
881
+ 'Blocked': []
882
+ // Don't include Skipped - we don't display them
627
883
  };
628
884
  if (!jobs)
629
885
  return grouped;
630
886
  for (const job of jobs) {
631
887
  const state = job.node.state?.toUpperCase() || '';
632
- if (state === 'FAILED' || state === 'BROKEN' || (job.node.exitStatus && job.node.exitStatus !== 0) || job.node.passed === false) {
633
- grouped['Failed'].push(job);
634
- }
635
- else if (state === 'PASSED' || (job.node.passed === true && state !== 'BROKEN')) {
636
- grouped['Passed'].push(job);
888
+ // If we have an exit status, use that as the source of truth
889
+ // Note: exitStatus comes as a string from Buildkite API
890
+ if (job.node.exitStatus !== null && job.node.exitStatus !== undefined) {
891
+ const exitCode = parseInt(job.node.exitStatus, 10);
892
+ if (exitCode === 0) {
893
+ grouped['Passed'].push(job);
894
+ }
895
+ else {
896
+ grouped['Failed'].push(job);
897
+ }
637
898
  }
638
899
  else if (state === 'RUNNING') {
639
900
  grouped['Running'].push(job);
@@ -641,12 +902,104 @@ export class PlainTextFormatter extends BaseBuildDetailFormatter {
641
902
  else if (state === 'BLOCKED') {
642
903
  grouped['Blocked'].push(job);
643
904
  }
644
- else if (state === 'SKIPPED' || state === 'CANCELED') {
645
- 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
908
+ }
909
+ else if (state === 'FINISHED' || state === 'COMPLETED') {
910
+ // For finished jobs without exit status, check passed field
911
+ if (job.node.passed === true) {
912
+ grouped['Passed'].push(job);
913
+ }
914
+ else if (job.node.passed === false) {
915
+ grouped['Failed'].push(job);
916
+ }
917
+ }
918
+ else if (state === 'PASSED' || job.node.passed === true) {
919
+ grouped['Passed'].push(job);
920
+ }
921
+ else if (state === 'FAILED') {
922
+ grouped['Failed'].push(job);
646
923
  }
647
924
  }
648
925
  return grouped;
649
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
+ }
650
1003
  countAnnotationsByStyle(annotations) {
651
1004
  const counts = {
652
1005
  ERROR: 0,