bktide
Version:
Command-line interface for Buildkite CI/CD workflows with rich shell completions (Fish, Bash, Zsh) and Alfred workflow integration for macOS power users
913 lines • 42.9 kB
JavaScript
import { BaseCommand } from './BaseCommand.js';
import { logger } from '../services/logger.js';
import { parseBuildRef } from '../utils/parseBuildRef.js';
import { Progress } from '../ui/progress.js';
import { getStateIcon, SEMANTIC_COLORS, BUILD_STATUS_THEME } from '../ui/theme.js';
import { formatDistanceToNow } from 'date-fns';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { BuildPoller } from '../services/BuildPoller.js';
import { getGitContext } from '../utils/gitContext.js';
import { parseGitRemoteUrl, generateRepoCandidates } from '../utils/repoUrl.js';
import { minimatch } from 'minimatch';
import { DOWNLOADABLE_ARTIFACT_STATES } from '../types/buildkite.js';
const TERMINAL_BUILD_STATES = ['PASSED', 'FAILED', 'CANCELED', 'BLOCKED', 'NOT_RUN'];
/**
* Categorize an error into a known category
*/
export function categorizeError(error) {
const message = error.message.toLowerCase();
if (message.includes('rate limit') || message.includes('429')) {
return { error: 'rate_limited', message: error.message, retryable: true };
}
if (message.includes('not found') || message.includes('404')) {
return { error: 'not_found', message: error.message, retryable: false };
}
if (message.includes('permission') || message.includes('403') || message.includes('401')) {
return { error: 'permission_denied', message: error.message, retryable: false };
}
if (message.includes('network') || message.includes('econnrefused') || message.includes('enotfound')) {
return { error: 'network_error', message: error.message, retryable: true };
}
return { error: 'unknown', message: error.message, retryable: true };
}
/**
* Format duration from milliseconds or date range
*/
function formatDuration(startedAt, finishedAt) {
if (!startedAt)
return '';
const start = new Date(startedAt).getTime();
const end = finishedAt ? new Date(finishedAt).getTime() : Date.now();
const seconds = Math.floor((end - start) / 1000);
if (seconds < 60)
return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60)
return `${minutes}m ${remainingSeconds}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
/**
* Generate a sanitized directory name for a step
*/
export function getStepDirName(index, label) {
const num = String(index + 1).padStart(2, '0');
const sanitized = label
.replace(/:[^:]+:/g, '') // Remove emoji shortcodes like :hammer:
.replace(/[^a-zA-Z0-9-]/g, '-') // Replace non-alphanumeric with dashes
.replace(/-+/g, '-') // Collapse multiple dashes
.replace(/^-|-$/g, '') // Trim leading/trailing dashes
.toLowerCase()
.slice(0, 50); // Limit length
return `${num}-${sanitized || 'step'}`;
}
/**
* Convert absolute path to use tilde (~) for home directory
* Makes paths more readable and portable
*/
function pathWithTilde(absolutePath) {
const homeDir = os.homedir();
if (absolutePath.startsWith(homeDir)) {
return absolutePath.replace(homeDir, '~');
}
return absolutePath;
}
/**
* Format path for display: use relative ./tmp/... for default location,
* tilde path for custom locations
*/
function pathForDisplay(absolutePath) {
const cwd = process.cwd();
const defaultBase = path.join(cwd, 'tmp', 'bktide', 'snapshots');
// If path is under default location, show as relative
if (absolutePath.startsWith(defaultBase)) {
return './' + path.relative(cwd, absolutePath);
}
// Otherwise use tilde path
return pathWithTilde(absolutePath);
}
export class Snapshot extends BaseCommand {
static requiresToken = true;
displayJobEvent(change) {
const time = change.timestamp.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const icon = getStateIcon(change.job.state);
const name = change.job.name || change.job.label || 'unknown';
const action = change.previousState === null ? 'started' : change.job.state;
logger.console(`${time} ${icon} ${name} ${action}`);
}
async execute(options) {
// Handle watch mode
if (options.watch) {
return this.executeWatchMode(options);
}
if (options.debug) {
logger.debug('Starting Snapshot command execution', options);
}
// If no build ref provided, try branch-aware inference
if (!options.buildRef) {
return this.executeBranchAware(options);
}
const format = options.format || 'plain';
const spinner = Progress.spinner('Fetching build data…', { format });
try {
await this.ensureInitialized();
// 1. Parse build reference
const buildRef = parseBuildRef(options.buildRef);
if (options.debug) {
logger.debug('Parsed build reference:', buildRef);
}
// 2. Determine output directory
const outputDir = this.getOutputDir(options, buildRef.org, buildRef.pipeline, buildRef.number);
if (options.debug) {
logger.debug('Output directory:', outputDir);
}
// 3. Fetch build data via GraphQL
spinner.update('Fetching build metadata…');
const buildSlug = `${buildRef.org}/${buildRef.pipeline}/${buildRef.number}`;
const buildData = await this.client.getBuildSummaryWithAllJobs(buildSlug, {
fetchAllJobs: true,
onProgress: (fetched, total) => {
const totalStr = total ? `/${total}` : '';
spinner.update(`Fetching jobs: ${fetched}${totalStr}…`);
}
});
const build = buildData.build;
const jobs = build.jobs?.edges || [];
// 4. Check for existing snapshot and detect changes
const existingManifest = await this.loadExistingManifest(outputDir);
const changeResult = this.detectChanges(build, jobs, existingManifest, options.force === true);
if (!changeResult.hasChanges) {
spinner.stop();
logger.console(`Snapshot already up to date: ${pathForDisplay(outputDir)}`);
// Still show navigation tips (displayNavigationTips adds its own leading blank line)
const scriptJobs = jobs
.map((edge) => edge.node)
.filter((job) => job.__typename === 'JobTypeCommand' || !job.__typename);
if (this.options.tips !== false && existingManifest) {
const annotationResult = existingManifest.annotations || { fetchStatus: 'none', count: 0 };
this.displayNavigationTips(outputDir, build, scriptJobs, existingManifest.steps.length, annotationResult, 0);
}
return 0;
}
if (options.debug && changeResult.reason) {
logger.debug(`Change detected: ${changeResult.reason}`);
if (changeResult.jobsToRefetch) {
logger.debug(`Jobs to refetch: ${changeResult.jobsToRefetch.length}`);
}
}
// 5. Create directory structure
spinner.update('Creating directories…');
await this.createDirectories(outputDir);
// 6. Save build.json
spinner.update('Saving build data…');
await this.saveBuildJson(outputDir, build);
// 7. Check and fetch annotations if changed
spinner.update('Checking annotations…');
let annotationResult;
const annotationsChanged = await this.checkAnnotationsChanged(buildSlug, existingManifest);
if (annotationsChanged || options.force) {
spinner.update('Fetching annotations…');
annotationResult = await this.fetchAndSaveAnnotations(outputDir, buildSlug, options.debug);
}
else {
// Use existing annotation data
annotationResult = existingManifest?.annotations
? { fetchStatus: existingManifest.annotations.fetchStatus, count: existingManifest.annotations.count, items: existingManifest.annotations.items }
: { fetchStatus: 'none', count: 0 };
if (options.debug) {
logger.debug('Annotations unchanged, using cached data');
}
}
// 8. Filter and fetch jobs
// Filter to script jobs only (JobTypeCommand)
const scriptJobs = jobs
.map((edge) => edge.node)
.filter((job) => job.__typename === 'JobTypeCommand' || !job.__typename);
// Determine which jobs to fetch based on options
// Default is --failed unless --all is specified
const fetchAll = options.all === true;
let jobsToFetch;
if (fetchAll) {
jobsToFetch = scriptJobs;
}
else {
// Filter to only failed jobs
jobsToFetch = scriptJobs.filter((job) => this.isFailedJob(job));
}
const totalJobs = jobsToFetch.length;
const stepResults = [];
// Stop the spinner before switching to progress bar
spinner.stop();
// Fetch logs for each job (if any) - use progress bar since we know the count
if (totalJobs > 0) {
const progressBar = Progress.bar({
total: totalJobs,
label: 'Fetching steps',
format,
});
for (let i = 0; i < jobsToFetch.length; i++) {
const job = jobsToFetch[i];
const stepName = job.name || job.label || 'step';
progressBar.update(i, `Fetching ${stepName}`);
const stepResult = await this.fetchAndSaveStep(outputDir, buildRef.org, buildRef.pipeline, buildRef.number, job, stepResults.length, options.debug);
stepResults.push(stepResult);
}
progressBar.complete(''); // Silent completion, count shown in summary
// Force stderr flush to prevent output interleaving
if (process.stderr.write) {
process.stderr.write('');
}
}
// 9. Fetch and save artifacts if requested
let artifactResult;
if (options.artifacts) {
logger.console(SEMANTIC_COLORS.muted('Fetching artifacts…'));
artifactResult = await this.fetchAndSaveArtifacts(outputDir, buildRef.org, buildRef.pipeline, buildRef.number, options.artifactGlob);
}
// 10. Write manifest
const manifest = this.buildManifest(buildRef.org, buildRef.pipeline, buildRef.number, build, stepResults, annotationResult, artifactResult);
await this.saveManifest(outputDir, manifest);
// 10. Output based on options
if (options.json) {
logger.console(JSON.stringify(manifest, null, 2));
}
else {
// Show build summary first
this.displayBuildSummary(build, scriptJobs);
// Then show snapshot info
const fetchErrorCount = stepResults.filter(s => s.status === 'failed').length;
// Note: displayBuildSummary() already ends with a blank line
logger.console(`Snapshot saved to ${pathForDisplay(outputDir)}`);
if (stepResults.length > 0) {
logger.console(` ${stepResults.length} step(s) captured`);
}
else if (!fetchAll) {
logger.console(` No failed steps to capture (build metadata saved)`);
}
else {
logger.console(` No steps to capture (build metadata saved)`);
}
if (annotationResult.count > 0) {
logger.console(` ${annotationResult.count} annotation(s) captured`);
}
else if (annotationResult.fetchStatus === 'none') {
if (options.debug) {
logger.console(` No annotations present`);
}
}
else if (annotationResult.fetchStatus === 'failed') {
logger.console(` Warning: Failed to fetch annotations`);
}
if (fetchErrorCount > 0) {
logger.console(` Warning: ${fetchErrorCount} step(s) had errors fetching logs`);
}
if (artifactResult?.fetchStatus === 'success' && artifactResult.count > 0) {
const filterNote = artifactResult.filter ? ` (filter: ${artifactResult.filter})` : '';
logger.console(` ${artifactResult.count} artifact(s) downloaded${filterNote}`);
}
else if (artifactResult?.fetchStatus === 'failed') {
logger.console(` Warning: Failed to fetch artifacts${artifactResult.error ? ': ' + artifactResult.error : ''}`);
}
else if (artifactResult?.fetchStatus === 'none') {
logger.debug(`No artifacts matched${artifactResult.filter ? ` '${artifactResult.filter}'` : ''}`);
}
// Track skipped count for tips section
const skippedCount = !fetchAll ? scriptJobs.length - jobsToFetch.length : 0;
// Show contextual navigation tips (check if tips are enabled)
if (this.options.tips !== false) {
this.displayNavigationTips(outputDir, build, scriptJobs, stepResults.length, annotationResult, skippedCount);
}
}
return manifest.fetchComplete ? 0 : 1;
}
catch (error) {
spinner.stop();
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to create snapshot: ${errorMessage}`);
if (options.debug && error instanceof Error && error.stack) {
logger.debug(error.stack);
}
return 1;
}
}
async executeWatchMode(options) {
if (!options.buildRef) {
logger.error('Build reference is required');
return 1;
}
await this.ensureInitialized();
const buildRef = parseBuildRef(options.buildRef);
const ref = {
org: buildRef.org,
pipeline: buildRef.pipeline,
buildNumber: buildRef.number,
};
const timeoutMinutes = parseInt(String(options.timeout || '30'), 10);
const pollIntervalSeconds = parseInt(String(options.pollInterval || '5'), 10);
logger.console(`Watching build #${buildRef.number} (timeout: ${timeoutMinutes}m)`);
logger.console(SEMANTIC_COLORS.muted('Will capture snapshot when build completes'));
logger.console(SEMANTIC_COLORS.muted('Press Ctrl+C to stop\n'));
const poller = new BuildPoller(this.restClient, {
onJobStateChange: (change) => this.displayJobEvent(change),
onBuildComplete: () => {
logger.console(SEMANTIC_COLORS.muted('\nBuild complete. Capturing snapshot...\n'));
},
onError: (err, willRetry) => {
if (willRetry) {
logger.console(SEMANTIC_COLORS.warning(`⚠ ${err.message}, retrying...`));
}
else {
logger.console(SEMANTIC_COLORS.error(`✗ ${err.message}`));
}
},
onTimeout: () => {
logger.console(SEMANTIC_COLORS.warning(`⏱ Timeout reached. Build still running.`));
},
}, {
initialInterval: pollIntervalSeconds * 1000,
timeout: timeoutMinutes * 60 * 1000,
});
const build = await poller.watch(ref);
// Only capture if build completed (not stopped/timed out)
if (build.state && ['passed', 'failed', 'canceled'].includes(build.state.toLowerCase())) {
// Run normal snapshot (without watch flag)
const snapshotOptions = { ...options, watch: false };
return this.execute(snapshotOptions);
}
return 1;
}
async executeBranchAware(options) {
const format = options.format || 'plain';
try {
await this.ensureInitialized();
// 1. Get git context (branch + remote URL)
let branch;
let remoteUrl;
if (options.branch) {
// Branch override provided, still need remote URL
branch = options.branch;
try {
const gitCtx = getGitContext();
remoteUrl = gitCtx.remoteUrl;
}
catch (error) {
logger.error('Could not determine git remote URL. Provide a build ref instead.');
return 1;
}
}
else {
try {
const gitCtx = getGitContext();
branch = gitCtx.branch;
remoteUrl = gitCtx.remoteUrl;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(message);
return 1;
}
}
if (options.debug) {
logger.debug('Branch-aware snapshot:', { branch, remoteUrl });
}
// 2. Parse remote URL and generate candidates
const parsed = parseGitRemoteUrl(remoteUrl);
const candidates = generateRepoCandidates(parsed);
if (options.debug) {
logger.debug('Repo URL candidates:', candidates);
}
// 3. Resolve organization
let orgSlug;
if (options.org) {
orgSlug = options.org;
}
else {
const orgSlugs = await this.client.getViewerOrganizationSlugs();
if (orgSlugs.length === 0) {
logger.error('No organizations found. Check your API token permissions.');
return 1;
}
if (orgSlugs.length > 1) {
logger.error(`Multiple organizations found: ${orgSlugs.join(', ')}. Use --org to specify which one.`);
return 1;
}
orgSlug = orgSlugs[0];
}
// 4. Query pipelines with builds for this branch
const spinner = Progress.spinner(`Searching for ${branch} builds...`, { format });
const pipelineBuilds = await this.client.getPipelineBuildsForRepo(orgSlug, candidates, branch);
spinner.stop();
if (pipelineBuilds.length === 0) {
logger.error(`No pipelines found matching ${parsed.org}/${parsed.repo}. Check your organization slug.`);
return 1;
}
// 5. Filter to pipelines that have builds on this branch
const withBuilds = pipelineBuilds.filter(p => p.build !== null);
if (withBuilds.length === 0) {
logger.console(`Found ${pipelineBuilds.length} pipeline(s) for ${parsed.org}/${parsed.repo}, but none have builds on branch ${SEMANTIC_COLORS.identifier(branch)}`);
logger.console('');
logger.console('Pipelines:');
for (const p of pipelineBuilds) {
logger.console(` ${SEMANTIC_COLORS.muted('-')} ${p.name} ${SEMANTIC_COLORS.muted(`(${p.slug})`)}`);
}
return 0;
}
// 6. Display summary
logger.console(`Branch ${SEMANTIC_COLORS.identifier(branch)} in ${parsed.org}/${parsed.repo}`);
logger.console('');
for (const p of withBuilds) {
const build = p.build;
const state = build.state || 'unknown';
const icon = getStateIcon(state);
const theme = BUILD_STATUS_THEME[state.toUpperCase()];
const coloredIcon = theme ? theme.color(icon) : icon;
const message = build.message?.split('\n')[0] || '';
const number = build.number;
const buildRef = `${orgSlug}/${p.slug}/${number}`;
logger.console(` ${coloredIcon} ${p.name} #${number} ${SEMANTIC_COLORS.muted(message)}`);
logger.console(` ${SEMANTIC_COLORS.muted(buildRef)}`);
}
logger.console('');
// 7. Snapshot all builds (logs are only fetched for failed steps by default)
logger.console(`Snapshotting ${withBuilds.length} build(s)...`);
logger.console('');
let hasFailure = false;
for (const p of withBuilds) {
const buildRef = `${orgSlug}/${p.slug}/${p.build.number}`;
const exitCode = await this.execute({ ...options, buildRef });
if (exitCode !== 0)
hasFailure = true;
logger.console('');
}
return hasFailure ? 1 : 0;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Branch-aware snapshot failed: ${errorMessage}`);
if (options.debug && error instanceof Error && error.stack) {
logger.debug(error.stack);
}
return 1;
}
}
getOutputDir(options, org, pipeline, buildNumber) {
const baseDir = options.outputDir || path.join(process.cwd(), 'tmp', 'bktide', 'snapshots');
return path.join(baseDir, org, pipeline, String(buildNumber));
}
async createDirectories(outputDir) {
const stepsDir = path.join(outputDir, 'steps');
await fs.mkdir(stepsDir, { recursive: true });
}
async saveBuildJson(outputDir, build) {
const buildPath = path.join(outputDir, 'build.json');
await fs.writeFile(buildPath, JSON.stringify(build, null, 2), 'utf-8');
}
async fetchAndSaveStep(outputDir, org, pipeline, buildNumber, job, stepIndex, debug) {
const stepDirName = getStepDirName(stepIndex, job.name || job.label || 'step');
const stepDir = path.join(outputDir, 'steps', stepDirName);
// Create step directory
await fs.mkdir(stepDir, { recursive: true });
// Save step.json (job metadata)
const stepPath = path.join(stepDir, 'step.json');
await fs.writeFile(stepPath, JSON.stringify(job, null, 2), 'utf-8');
// Try to fetch and save log
try {
const logData = await this.restClient.getJobLog(org, pipeline, buildNumber, job.uuid);
const logPath = path.join(stepDir, 'log.txt');
await fs.writeFile(logPath, logData.content || '', 'utf-8');
return {
id: stepDirName,
jobId: job.id,
status: 'success',
job: job, // Add full job object
};
}
catch (error) {
if (debug) {
logger.debug(`Failed to fetch log for job ${job.id}:`, error);
}
const errorInfo = categorizeError(error instanceof Error ? error : new Error(String(error)));
return {
id: stepDirName,
jobId: job.id,
status: 'failed',
job: job, // Add full job object
error: errorInfo.error,
message: errorInfo.message,
retryable: errorInfo.retryable,
};
}
}
async fetchAndSaveAnnotations(outputDir, buildSlug, debug) {
try {
const annotations = await this.client.getAnnotationsFull(buildSlug);
if (debug) {
logger.debug(`Fetched ${annotations.length} annotation(s)`);
}
// Save annotations.json
const annotationsFile = {
fetchedAt: new Date().toISOString(),
count: annotations.length,
annotations: annotations,
};
const annotationsPath = path.join(outputDir, 'annotations.json');
await fs.writeFile(annotationsPath, JSON.stringify(annotationsFile, null, 2), 'utf-8');
// Return result with items for change detection
const items = annotations.map((a) => ({ uuid: a.uuid, updatedAt: a.updatedAt || null }));
if (annotations.length === 0) {
return { fetchStatus: 'none', count: 0, items: [] };
}
return { fetchStatus: 'success', count: annotations.length, items };
}
catch (error) {
if (debug) {
logger.debug(`Failed to fetch annotations:`, error);
}
const errorInfo = categorizeError(error instanceof Error ? error : new Error(String(error)));
return {
fetchStatus: 'failed',
count: 0,
error: errorInfo.error,
message: errorInfo.message,
};
}
}
async fetchAndSaveArtifacts(outputDir, org, pipeline, buildNumber, glob) {
try {
const allArtifacts = await this.restClient.listBuildArtifacts(org, pipeline, buildNumber);
const targets = glob
? allArtifacts.filter(a => DOWNLOADABLE_ARTIFACT_STATES.has(a.state) && minimatch(a.path, glob, { matchBase: true }))
: allArtifacts.filter(a => DOWNLOADABLE_ARTIFACT_STATES.has(a.state));
if (targets.length === 0) {
return { fetchStatus: 'none', count: 0, filter: glob };
}
const artifactsDir = path.join(outputDir, 'artifacts');
await fs.mkdir(artifactsDir, { recursive: true });
const downloaded = [];
const failed = [];
for (const artifact of targets) {
const safePath = path.normalize(artifact.path).replace(/^(\.\.(\/|\\|$))+/, '');
const destPath = path.join(artifactsDir, safePath);
try {
await this.restClient.downloadArtifact(artifact, destPath);
downloaded.push(artifact);
logger.debug(`Downloaded artifact: ${artifact.path}`);
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
failed.push({ path: artifact.path, error: msg });
logger.debug(`Failed to download artifact ${artifact.path}: ${msg}`);
}
}
return {
fetchStatus: failed.length === 0 ? 'success' : (downloaded.length === 0 ? 'failed' : 'success'),
count: downloaded.length,
filter: glob,
items: downloaded.map(a => ({
id: a.id,
jobId: a.job_id,
path: a.path,
file_size: a.file_size,
sha1sum: a.sha1sum,
mime_type: a.mime_type,
})),
};
}
catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.debug(`Failed to fetch artifacts: ${msg}`);
return { fetchStatus: 'failed', count: 0, filter: glob, error: msg };
}
}
buildManifest(org, pipeline, buildNumber, build, stepResults, annotationResult, artifactResult) {
const allFetchesSucceeded = stepResults.every(s => s.status === 'success');
const fetchErrors = stepResults.filter(s => s.status === 'failed');
const manifest = {
version: 3,
buildRef: `${org}/${pipeline}/${buildNumber}`,
url: `https://buildkite.com/${org}/${pipeline}/builds/${buildNumber}`,
fetchedAt: new Date().toISOString(),
fetchComplete: allFetchesSucceeded && annotationResult.fetchStatus !== 'failed',
build: {
state: build.state || 'unknown',
number: build.number,
message: build.message?.split('\n')[0] || '',
branch: build.branch || 'unknown',
commit: build.commit?.substring(0, 7) || 'unknown',
finishedAt: build.finishedAt || null,
},
annotations: {
fetchStatus: annotationResult.fetchStatus,
count: annotationResult.count,
items: annotationResult.items,
},
...(artifactResult && artifactResult.fetchStatus !== 'skipped' && {
artifacts: {
fetchStatus: artifactResult.fetchStatus,
count: artifactResult.count,
filter: artifactResult.filter,
items: artifactResult.items,
},
}),
steps: stepResults.map(result => ({
// Our metadata
id: result.id,
fetchStatus: result.status,
// Buildkite job metadata (flat structure)
jobId: result.jobId,
type: result.job.type || 'script',
name: result.job.name || '',
label: result.job.label || '',
state: result.job.state || 'unknown',
exit_status: result.job.exitStatus ?? null,
started_at: result.job.startedAt || null,
finished_at: result.job.finishedAt || null,
})),
};
// Only include fetchErrors if any exist
if (fetchErrors.length > 0) {
manifest.fetchErrors = fetchErrors.map(err => ({
id: err.id,
jobId: err.jobId,
fetchStatus: 'failed',
error: err.error,
message: err.message,
retryable: err.retryable,
}));
}
return manifest;
}
async saveManifest(outputDir, manifest) {
const manifestPath = path.join(outputDir, 'manifest.json');
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
}
/**
* Load existing manifest if present
* Returns null if no manifest exists or parsing fails
*/
async loadExistingManifest(outputDir) {
const manifestPath = path.join(outputDir, 'manifest.json');
try {
const content = await fs.readFile(manifestPath, 'utf-8');
const manifest = JSON.parse(content);
// Accept v2 and v3 manifests (v3 adds optional artifacts section)
if (manifest.version !== 2 && manifest.version !== 3) {
return null;
}
return manifest;
}
catch {
return null;
}
}
/**
* Detect what has changed since last snapshot
* Compares current build state against stored manifest
*/
detectChanges(currentBuild, currentJobs, existingManifest, force) {
// Force refresh requested
if (force) {
return { hasChanges: true, reason: 'force_refresh' };
}
// No existing manifest - full fetch needed
if (!existingManifest) {
return { hasChanges: true, reason: 'no_existing_manifest' };
}
const currentState = currentBuild.state?.toUpperCase();
// Build still running - always re-fetch
if (!TERMINAL_BUILD_STATES.includes(currentState)) {
return { hasChanges: true, reason: 'build_running' };
}
// Check if build finished at different time (rebuild scenario)
const currentFinishedAt = currentBuild.finishedAt;
const storedFinishedAt = existingManifest.build.finishedAt;
if (currentFinishedAt !== storedFinishedAt) {
return { hasChanges: true, reason: 'build_finished_changed' };
}
// For terminal builds with matching finishedAt, the build can't have changed
// Skip job-level comparison since we may have only stored a subset (e.g., --failed mode)
if (existingManifest.fetchComplete) {
return { hasChanges: false };
}
// Compare job states (only for incomplete fetches)
const jobsToRefetch = [];
const storedJobMap = new Map(existingManifest.steps.map(s => [s.jobId, s]));
for (const jobEdge of currentJobs) {
const job = jobEdge.node;
if (job.__typename !== 'JobTypeCommand' && job.__typename)
continue;
const storedJob = storedJobMap.get(job.id);
// New job - needs fetch
if (!storedJob) {
jobsToRefetch.push(job.id);
continue;
}
// Job state changed
if (job.state !== storedJob.state) {
jobsToRefetch.push(job.id);
continue;
}
// Job finished at different time
if (job.finishedAt !== storedJob.finished_at) {
jobsToRefetch.push(job.id);
}
}
// If any jobs need re-fetching, there are changes
if (jobsToRefetch.length > 0) {
return {
hasChanges: true,
reason: 'build_finished_changed',
jobsToRefetch,
};
}
// No changes detected
return { hasChanges: false };
}
/**
* Check if annotations have changed
* Returns true if any annotation is new or has been updated
*/
async checkAnnotationsChanged(buildSlug, existingManifest) {
if (!existingManifest?.annotations?.items) {
return true; // No stored annotations, need to fetch
}
try {
const currentTimestamps = await this.client.getAnnotationTimestamps(buildSlug);
// Create map of stored annotations
const storedMap = new Map(existingManifest.annotations.items.map(a => [a.uuid, a.updatedAt]));
// Check for new or updated annotations
for (const current of currentTimestamps) {
const storedUpdatedAt = storedMap.get(current.uuid);
if (storedUpdatedAt === undefined) {
return true; // New annotation
}
if (current.updatedAt !== storedUpdatedAt) {
return true; // Updated annotation
}
}
// Check count matches
if (currentTimestamps.length !== existingManifest.annotations.items.length) {
return true; // Annotation count changed (deleted)
}
return false;
}
catch {
return true; // Error checking, re-fetch to be safe
}
}
/**
* Check if a job is considered failed
* Failed states: failed, timed_out, or non-zero exit status
*/
isFailedJob(job) {
const state = job.state?.toUpperCase();
// Check state-based failure
if (state === 'FAILED' || state === 'TIMED_OUT') {
return true;
}
// Check exit status (non-zero means failure, including soft failures)
if (job.exitStatus !== null && job.exitStatus !== undefined) {
const exitCode = parseInt(job.exitStatus, 10);
return exitCode !== 0;
}
// Check passed field
if (job.passed === false) {
return true;
}
return false;
}
/**
* Get directory name of first failed step for concrete example in tips
*/
getFirstFailedStepDir(scriptJobs) {
for (let i = 0; i < scriptJobs.length; i++) {
const job = scriptJobs[i];
if (this.isFailedJob(job)) {
return getStepDirName(i, job.name || job.label || 'step');
}
}
return null;
}
/**
* Display contextual navigation tips based on build state
*/
displayNavigationTips(outputDir, build, scriptJobs, capturedCount, annotationResult, skippedCount) {
const buildState = build.state?.toLowerCase();
const isFailed = buildState === 'failed' || buildState === 'failing';
// Use relative paths for readability (string concat preserves ./ prefix)
const basePath = pathForDisplay(outputDir);
const manifestPath = `${basePath}/manifest.json`;
const stepsPath = `${basePath}/steps`;
const annotationsPath = `${basePath}/annotations.json`;
// Output tips using logger.console to maintain consistent output ordering
// (reporter.tips uses direct stdout which can race with pino's buffering)
logger.console(' '); // Blank line before Next steps
logger.console('Next steps:');
if (isFailed) {
// Tips for failed builds
logger.console(` → List failures: jq -r '.steps[] | select(.state == "failed") | "\\(.id): \\(.label)"' ${manifestPath}`);
// Add annotation tip if annotations exist
if (annotationResult.count > 0) {
logger.console(` → View annotations: jq -r '.annotations[] | {context, style}' ${annotationsPath}`);
}
logger.console(` → Get exit codes: jq -r '.steps[] | "\\(.id): exit \\(.exit_status)"' ${manifestPath}`);
// If we captured steps, show how to view first failed log
if (capturedCount > 0) {
const firstFailedDir = this.getFirstFailedStepDir(scriptJobs);
if (firstFailedDir) {
logger.console(` → View a log: cat ${stepsPath}/${firstFailedDir}/log.txt`);
}
}
logger.console(` → Search errors: grep -r "Error\\|Failed\\|Exception" ${stepsPath}/`);
// Show --all tip if steps were skipped
if (skippedCount > 0) {
logger.console(` → Use --all to include all ${skippedCount} passing steps`);
}
}
else {
// Tips for passed builds
logger.console(` → List all steps: jq -r '.steps[] | "\\(.id): \\(.label) (\\(.state))"' ${manifestPath}`);
logger.console(` → Browse logs: ls ${stepsPath}/`);
if (capturedCount > 0) {
logger.console(` → View a log: cat ${stepsPath}/01-*/log.txt`);
}
// Show --all tip if steps were skipped (for passed builds using default filter)
if (skippedCount > 0) {
logger.console(` → Use --all to include all ${skippedCount} passing steps`);
}
}
logger.console(` → Use --no-tips to hide these hints`);
logger.console(' ');
logger.console(SEMANTIC_COLORS.dim(` → manifest.json has full build metadata and step index`));
}
/**
* Display build summary similar to `build` command
*/
displayBuildSummary(build, scriptJobs) {
const state = build.state || 'unknown';
const icon = getStateIcon(state);
const theme = BUILD_STATUS_THEME[state.toUpperCase()];
const coloredIcon = theme ? theme.color(icon) : icon;
const message = build.message?.split('\n')[0] || 'No message';
const duration = formatDuration(build.startedAt, build.finishedAt);
const durationStr = duration ? ` ${SEMANTIC_COLORS.dim(duration)}` : '';
// First line: status + message + build number + duration
const coloredState = theme ? theme.color(state.toUpperCase()) : state.toUpperCase();
logger.console(`${coloredIcon} ${coloredState} ${message} ${SEMANTIC_COLORS.dim(`#${build.number}`)}${durationStr}`);
// Second line: author + branch + commit + time
const author = build.createdBy?.name || build.createdBy?.email || 'Unknown';
const branch = build.branch || 'unknown';
const commit = build.commit?.substring(0, 7) || 'unknown';
const created = build.createdAt ? formatDistanceToNow(new Date(build.createdAt), { addSuffix: true }) : '';
logger.console(` ${author} • ${SEMANTIC_COLORS.identifier(branch)} • ${commit} • ${SEMANTIC_COLORS.dim(created)}`);
// Job statistics
const passed = scriptJobs.filter(j => {
if (j.exitStatus !== null && j.exitStatus !== undefined) {
return parseInt(j.exitStatus, 10) === 0;
}
return j.state === 'PASSED' || j.passed === true;
}).length;
const hardFailed = scriptJobs.filter(j => {
if (j.exitStatus !== null && j.exitStatus !== undefined) {
const exitCode = parseInt(j.exitStatus, 10);
return exitCode !== 0 && j.softFailed !== true;
}
return (j.state === 'FAILED' || j.passed === false) && j.softFailed !== true;
}).length;
const softFailed = scriptJobs.filter(j => {
if (j.exitStatus !== null && j.exitStatus !== undefined) {
const exitCode = parseInt(j.exitStatus, 10);
return exitCode !== 0 && j.softFailed === true;
}
return (j.state === 'FAILED' || j.passed === false) && j.softFailed === true;
}).length;
const running = scriptJobs.filter(j => j.state === 'RUNNING').length;
const other = scriptJobs.length - passed - hardFailed - softFailed - running;
let statsStr = `${scriptJobs.length} steps:`;
const parts = [];
if (passed > 0)
parts.push(SEMANTIC_COLORS.success(`${passed} passed`));
if (hardFailed > 0)
parts.push(SEMANTIC_COLORS.error(`${hardFailed} failed`));
if (softFailed > 0)
parts.push(SEMANTIC_COLORS.warning(`▲ ${softFailed} soft failure${softFailed > 1 ? 's' : ''}`));
if (running > 0)
parts.push(SEMANTIC_COLORS.info(`${running} running`));
if (other > 0)
parts.push(SEMANTIC_COLORS.muted(`${other} other`));
statsStr += ' ' + parts.join(', ');
logger.console(' ');
logger.console(statsStr);
logger.console(' ');
}
}
//# sourceMappingURL=Snapshot.js.map