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
307 lines • 13.1 kB
JavaScript
/**
* DataProfiler extracts statistical patterns from real data without storing sensitive information
*/
export class DataProfiler {
profileBuilds(builds) {
if (!builds || builds.length === 0) {
return this.getEmptyBuildPatterns();
}
return {
states: this.getDistribution(builds.map(b => b.state)),
branches: this.analyzeBranches(builds.map(b => b.branch || 'unknown')),
messagePatterns: this.analyzeMessages(builds.map(b => b.message || '')),
numberRange: {
min: Math.min(...builds.map(b => b.number || 0)),
max: Math.max(...builds.map(b => b.number || 0))
},
duration: this.analyzeDurations(builds),
jobCounts: this.getDistribution(builds.map(b => b.jobs?.edges?.length || 0)),
annotationCounts: this.getDistribution(builds.map(b => b.annotations?.edges?.length || 0)),
creatorPatterns: this.analyzeCreators(builds)
};
}
profilePipelines(pipelines) {
if (!pipelines || pipelines.length === 0) {
return this.getEmptyPipelinePatterns();
}
const names = pipelines.map(p => p.name || '');
const slugs = pipelines.map(p => p.slug || '');
return {
slugFormats: this.analyzeSlugFormats(slugs),
nameLength: {
min: Math.min(...names.map(n => n.length)),
max: Math.max(...names.map(n => n.length)),
average: names.reduce((acc, n) => acc + n.length, 0) / names.length
},
defaultBranches: this.getDistribution(pipelines.map(p => p.defaultBranch || 'main')),
hasDescription: pipelines.filter(p => p.description).length / pipelines.length,
repositoryProviders: this.getDistribution(pipelines.map(p => this.extractRepoProvider(p.repository?.url))),
visibility: this.getDistribution(pipelines.map(p => p.visibility || 'PRIVATE'))
};
}
profileJobs(jobs) {
if (!jobs || jobs.length === 0) {
return this.getEmptyJobPatterns();
}
return {
states: this.getDistribution(jobs.map(j => j.__typename || 'JobTypeCommand')),
labelPatterns: this.analyzeJobLabels(jobs),
exitStatusDistribution: this.getDistribution(jobs.map(j => 'exitStatus' in j ? j.exitStatus : null)),
retryRates: {
automatic: jobs.filter(j => 'retriedAutomatically' in j && j.retriedAutomatically).length / jobs.length,
manual: jobs.filter(j => 'retriedManually' in j && j.retriedManually).length / jobs.length
},
parallelGroups: this.getDistribution(jobs.map(j => 'parallelGroupTotal' in j ? j.parallelGroupTotal || 1 : 1)),
durationPatterns: this.analyzeJobDurations(jobs)
};
}
profileOrganizations(orgs) {
if (!orgs || orgs.length === 0) {
return this.getEmptyOrganizationPatterns();
}
const names = orgs.map(o => o.name || '');
const slugs = orgs.map(o => o.slug || '');
return {
slugFormats: this.analyzeSlugFormats(slugs),
nameLength: {
min: Math.min(...names.map(n => n.length)),
max: Math.max(...names.map(n => n.length)),
average: names.reduce((acc, n) => acc + n.length, 0) / names.length
},
pipelineCount: this.getDistribution(orgs.map(o => o.pipelines?.edges?.length || 0)),
memberCount: this.getDistribution(orgs.map(o => o.members?.edges?.length || 0))
};
}
getDistribution(values) {
const counts = new Map();
values.forEach(v => counts.set(v, (counts.get(v) || 0) + 1));
const total = values.length;
const distribution = {
values: Array.from(counts.entries()).map(([value, count]) => ({
value,
frequency: count / total,
count
})).sort((a, b) => b.frequency - a.frequency),
total
};
return distribution;
}
analyzeBranches(branches) {
const formats = {
feature: branches.filter(b => b.startsWith('feature/')).length / branches.length,
bugfix: branches.filter(b => b.startsWith('bugfix/') || b.startsWith('fix/')).length / branches.length,
release: branches.filter(b => b.match(/^release\/\d+\.\d+/)).length / branches.length,
main: branches.filter(b => ['main', 'master', 'develop'].includes(b)).length / branches.length,
custom: 0
};
formats.custom = 1 - (formats.feature + formats.bugfix + formats.release + formats.main);
return {
common: this.getDistribution(this.getCommonPrefixes(branches)),
formats,
averageLength: branches.reduce((acc, b) => acc + b.length, 0) / branches.length
};
}
analyzeMessages(messages) {
const nonEmpty = messages.filter(m => m.length > 0);
if (nonEmpty.length === 0) {
return {
averageLength: 0,
hasEmoji: 0,
conventionalCommits: 0,
commonPrefixes: { values: [], total: 0 },
githubRefs: 0,
jiraRefs: 0,
multiline: 0
};
}
return {
averageLength: nonEmpty.reduce((acc, m) => acc + m.length, 0) / nonEmpty.length,
hasEmoji: nonEmpty.filter(m => /[🎉🚀✨🔧📦👷♂️🐛⚡️✅💚🔥]/.test(m)).length / nonEmpty.length,
conventionalCommits: nonEmpty.filter(m => /^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?:/.test(m)).length / nonEmpty.length,
commonPrefixes: this.getDistribution(this.getCommonPrefixes(nonEmpty)),
githubRefs: nonEmpty.filter(m => /#\d+/.test(m)).length / nonEmpty.length,
jiraRefs: nonEmpty.filter(m => /[A-Z]{2,}-\d+/.test(m)).length / nonEmpty.length,
multiline: nonEmpty.filter(m => m.includes('\n')).length / nonEmpty.length
};
}
analyzeDurations(builds) {
const durations = builds
.filter(b => b.startedAt && b.finishedAt)
.map(b => new Date(b.finishedAt).getTime() - new Date(b.startedAt).getTime());
if (durations.length === 0) {
return { min: 0, max: 0, median: 0, p95: 0, average: 0 };
}
durations.sort((a, b) => a - b);
return {
min: durations[0],
max: durations[durations.length - 1],
median: durations[Math.floor(durations.length / 2)],
p95: durations[Math.floor(durations.length * 0.95)],
average: durations.reduce((acc, d) => acc + d, 0) / durations.length
};
}
analyzeJobDurations(jobs) {
const durations = jobs
.filter(j => 'startedAt' in j && 'finishedAt' in j)
.map(j => {
const job = j; // Type assertion for jobs with these fields
if (job.startedAt && job.finishedAt) {
return new Date(job.finishedAt).getTime() - new Date(job.startedAt).getTime();
}
return null;
})
.filter((d) => d !== null);
if (durations.length === 0) {
return { min: 0, max: 0, median: 0, p95: 0, average: 0 };
}
durations.sort((a, b) => a - b);
return {
min: durations[0],
max: durations[durations.length - 1],
median: durations[Math.floor(durations.length / 2)],
p95: durations[Math.floor(durations.length * 0.95)],
average: durations.reduce((acc, d) => acc + d, 0) / durations.length
};
}
analyzeCreators(builds) {
const creators = builds.map(b => b.createdBy);
const emails = creators
.map(c => c?.email || '')
.filter(e => e.length > 0);
const domains = emails.map(e => e.split('@')[1] || 'unknown');
const names = creators.map(c => c?.name || 'unknown');
return {
nameFormats: this.getDistribution(names.map(n => this.classifyNameFormat(n))),
domains: this.getDistribution(domains),
botUsers: creators.filter(c => c?.name?.toLowerCase().includes('bot') ||
c?.email?.toLowerCase().includes('bot') ||
c?.name?.toLowerCase().includes('[bot]')).length / creators.length
};
}
analyzeSlugFormats(slugs) {
const formats = slugs.map(s => {
if (s.includes('-'))
return 'kebab-case';
if (s.includes('_'))
return 'snake_case';
if (s.match(/[A-Z]/))
return 'camelCase';
return 'lowercase';
});
return this.getDistribution(formats);
}
analyzeJobLabels(jobs) {
const labels = jobs
.map(j => 'label' in j ? j.label : null)
.filter((l) => l !== null);
// Extract common patterns
const patterns = labels.map(l => {
// Replace common variables with placeholders
return l
.replace(/\d+\.\d+\.\d+/g, ':version')
.replace(/node-\d+/gi, 'node-:version')
.replace(/python-\d+\.\d+/gi, 'python-:version')
.replace(/ruby-\d+\.\d+/gi, 'ruby-:version')
.replace(/(linux|ubuntu|macos|windows|darwin)/gi, ':os')
.replace(/(test|tests|spec|specs)/gi, ':test')
.replace(/\b[a-f0-9]{7,40}\b/g, ':sha');
});
return this.getDistribution(patterns);
}
getCommonPrefixes(strings, maxLength = 50) {
const prefixes = [];
strings.forEach(str => {
const words = str.split(/[\s\-_/:]+/).filter(w => w.length > 0);
if (words.length > 0) {
prefixes.push(words[0].substring(0, maxLength));
}
});
return prefixes;
}
extractRepoProvider(url) {
if (!url)
return 'unknown';
if (url.includes('github.com'))
return 'github';
if (url.includes('gitlab.com'))
return 'gitlab';
if (url.includes('bitbucket.org'))
return 'bitbucket';
if (url.includes('git'))
return 'git';
return 'other';
}
classifyNameFormat(name) {
if (name.includes(' '))
return 'full-name';
if (name.includes('.'))
return 'dotted';
if (name.includes('-'))
return 'hyphenated';
if (name.includes('_'))
return 'underscored';
if (name.match(/^[a-z]+$/))
return 'lowercase';
if (name.match(/^[A-Z]+$/))
return 'uppercase';
return 'mixed';
}
// Empty pattern generators for fallback
getEmptyBuildPatterns() {
return {
states: { values: [], total: 0 },
branches: {
common: { values: [], total: 0 },
formats: { feature: 0, bugfix: 0, release: 0, main: 0, custom: 0 },
averageLength: 0
},
messagePatterns: {
averageLength: 0,
hasEmoji: 0,
conventionalCommits: 0,
commonPrefixes: { values: [], total: 0 },
githubRefs: 0,
jiraRefs: 0,
multiline: 0
},
numberRange: { min: 0, max: 0 },
duration: { min: 0, max: 0, median: 0, p95: 0, average: 0 },
jobCounts: { values: [], total: 0 },
annotationCounts: { values: [], total: 0 },
creatorPatterns: {
nameFormats: { values: [], total: 0 },
domains: { values: [], total: 0 },
botUsers: 0
}
};
}
getEmptyPipelinePatterns() {
return {
slugFormats: { values: [], total: 0 },
nameLength: { min: 0, max: 0, average: 0 },
defaultBranches: { values: [], total: 0 },
hasDescription: 0,
repositoryProviders: { values: [], total: 0 },
visibility: { values: [], total: 0 }
};
}
getEmptyJobPatterns() {
return {
states: { values: [], total: 0 },
labelPatterns: { values: [], total: 0 },
exitStatusDistribution: { values: [], total: 0 },
retryRates: { automatic: 0, manual: 0 },
parallelGroups: { values: [], total: 0 },
durationPatterns: { min: 0, max: 0, median: 0, p95: 0, average: 0 }
};
}
getEmptyOrganizationPatterns() {
return {
slugFormats: { values: [], total: 0 },
nameLength: { min: 0, max: 0, average: 0 },
pipelineCount: { values: [], total: 0 },
memberCount: { values: [], total: 0 }
};
}
}
//# sourceMappingURL=DataProfiler.js.map