UNPKG

bktide

Version:

Command-line interface for Buildkite CI/CD workflows with rich shell completions (Fish, Bash, Zsh) and Alfred workflow integration for macOS power users

913 lines 42.9 kB
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