create-qa-architect 5.0.7 ā 5.4.3
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.
- package/.github/workflows/auto-release.yml +49 -0
- package/.github/workflows/quality.yml +11 -11
- package/.github/workflows/shell-ci.yml.example +82 -0
- package/.github/workflows/shell-quality.yml.example +148 -0
- package/README.md +165 -12
- package/config/shell-ci.yml +82 -0
- package/config/shell-quality.yml +148 -0
- package/docs/ADOPTION-SUMMARY.md +41 -0
- package/docs/ARCHITECTURE-REVIEW.md +67 -0
- package/docs/ARCHITECTURE.md +29 -45
- package/docs/CI-COST-ANALYSIS.md +323 -0
- package/docs/CODE-REVIEW.md +100 -0
- package/docs/REQUIREMENTS.md +148 -0
- package/docs/SECURITY-AUDIT.md +68 -0
- package/docs/test-trace-matrix.md +28 -0
- package/eslint.config.cjs +2 -0
- package/lib/commands/analyze-ci.js +616 -0
- package/lib/commands/deps.js +293 -0
- package/lib/commands/index.js +29 -0
- package/lib/commands/validate.js +85 -0
- package/lib/config-validator.js +28 -45
- package/lib/error-reporter.js +14 -2
- package/lib/github-api.js +138 -13
- package/lib/license-signing.js +125 -0
- package/lib/license-validator.js +359 -71
- package/lib/licensing.js +434 -106
- package/lib/package-utils.js +9 -9
- package/lib/prelaunch-validator.js +828 -0
- package/lib/project-maturity.js +58 -6
- package/lib/quality-tools-generator.js +495 -0
- package/lib/result-types.js +112 -0
- package/lib/security-enhancements.js +1 -1
- package/lib/smart-strategy-generator.js +46 -10
- package/lib/telemetry.js +1 -1
- package/lib/template-loader.js +52 -19
- package/lib/ui-helpers.js +1 -1
- package/lib/validation/cache-manager.js +36 -6
- package/lib/validation/config-security.js +100 -33
- package/lib/validation/index.js +68 -97
- package/lib/validation/workflow-validation.js +28 -7
- package/package.json +4 -6
- package/scripts/check-test-coverage.sh +46 -0
- package/scripts/validate-claude-md.js +80 -0
- package/setup.js +923 -301
- package/create-saas-monetization.js +0 -1513
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub Actions Cost Analyzer
|
|
5
|
+
*
|
|
6
|
+
* Analyzes GitHub Actions usage patterns and provides cost optimization recommendations.
|
|
7
|
+
* Pro feature that helps developers avoid unexpected CI/CD bills.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
const { execSync } = require('child_process')
|
|
13
|
+
const yaml = require('js-yaml')
|
|
14
|
+
const { showProgress } = require('../ui-helpers')
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Discover all GitHub Actions workflow files in the project
|
|
18
|
+
* @param {string} projectPath - Root path of the project
|
|
19
|
+
* @returns {{name: string, path: string}[]} Array of workflow files
|
|
20
|
+
*/
|
|
21
|
+
function discoverWorkflows(projectPath) {
|
|
22
|
+
const workflowDir = path.join(projectPath, '.github', 'workflows')
|
|
23
|
+
|
|
24
|
+
if (!fs.existsSync(workflowDir)) {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const files = fs.readdirSync(workflowDir)
|
|
29
|
+
return files
|
|
30
|
+
.filter(file => file.endsWith('.yml') || file.endsWith('.yaml'))
|
|
31
|
+
.map(file => ({
|
|
32
|
+
name: file,
|
|
33
|
+
path: path.join(workflowDir, file),
|
|
34
|
+
}))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Estimate workflow duration based on job steps
|
|
39
|
+
* @param {object} workflow - Parsed YAML workflow object
|
|
40
|
+
* @returns {number} Estimated duration in minutes
|
|
41
|
+
*/
|
|
42
|
+
function estimateWorkflowDuration(workflow) {
|
|
43
|
+
if (!workflow.jobs) {
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let totalMinutes = 0
|
|
48
|
+
|
|
49
|
+
for (const [_jobName, job] of Object.entries(workflow.jobs)) {
|
|
50
|
+
// Default job duration estimate: 5 minutes
|
|
51
|
+
let jobMinutes = 5
|
|
52
|
+
|
|
53
|
+
if (job.steps && Array.isArray(job.steps)) {
|
|
54
|
+
// Estimate based on known operations
|
|
55
|
+
for (const step of job.steps) {
|
|
56
|
+
// Check for expensive operations
|
|
57
|
+
if (step.name) {
|
|
58
|
+
const stepName = step.name.toLowerCase()
|
|
59
|
+
|
|
60
|
+
// Known expensive operations
|
|
61
|
+
if (stepName.includes('test') || stepName.includes('e2e')) {
|
|
62
|
+
jobMinutes += 10 // Tests typically take longer
|
|
63
|
+
} else if (
|
|
64
|
+
stepName.includes('build') ||
|
|
65
|
+
stepName.includes('compile')
|
|
66
|
+
) {
|
|
67
|
+
jobMinutes += 5
|
|
68
|
+
} else if (
|
|
69
|
+
stepName.includes('deploy') ||
|
|
70
|
+
stepName.includes('publish')
|
|
71
|
+
) {
|
|
72
|
+
jobMinutes += 3
|
|
73
|
+
} else if (
|
|
74
|
+
stepName.includes('install') ||
|
|
75
|
+
stepName.includes('setup')
|
|
76
|
+
) {
|
|
77
|
+
jobMinutes += 2
|
|
78
|
+
} else {
|
|
79
|
+
jobMinutes += 1 // Generic step
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
jobMinutes += 1 // Generic step without name
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Cap individual job at reasonable limits
|
|
87
|
+
jobMinutes = Math.min(jobMinutes, 60) // Max 60 min per job
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check for matrix strategy (multiplies job runs)
|
|
91
|
+
if (job.strategy && job.strategy.matrix) {
|
|
92
|
+
const matrixSize = calculateMatrixSize(job.strategy.matrix)
|
|
93
|
+
jobMinutes *= matrixSize
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
totalMinutes += jobMinutes
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return Math.ceil(totalMinutes)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Calculate the size of a GitHub Actions matrix strategy
|
|
104
|
+
* @param {object} matrix - Matrix configuration
|
|
105
|
+
* @returns {number} Number of matrix combinations
|
|
106
|
+
*/
|
|
107
|
+
function calculateMatrixSize(matrix) {
|
|
108
|
+
let size = 1
|
|
109
|
+
|
|
110
|
+
for (const [_key, values] of Object.entries(matrix)) {
|
|
111
|
+
if (Array.isArray(values)) {
|
|
112
|
+
size *= values.length
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return size
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get commit frequency from git log
|
|
121
|
+
* @param {string} projectPath - Root path of the project
|
|
122
|
+
* @param {number} days - Number of days to analyze (default: 30)
|
|
123
|
+
* @returns {{commitsPerDay: number, totalCommits: number}} Commit frequency stats
|
|
124
|
+
*/
|
|
125
|
+
function getCommitFrequency(projectPath, days = 30) {
|
|
126
|
+
try {
|
|
127
|
+
// Safe: No user input, hardcoded git command
|
|
128
|
+
const gitLog = execSync('git log --oneline --since="30 days ago" --all', {
|
|
129
|
+
cwd: projectPath,
|
|
130
|
+
encoding: 'utf8',
|
|
131
|
+
stdio: ['pipe', 'pipe', 'ignore'], // Suppress stderr
|
|
132
|
+
}).trim()
|
|
133
|
+
|
|
134
|
+
if (!gitLog) {
|
|
135
|
+
return { commitsPerDay: 0, totalCommits: 0 }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const totalCommits = gitLog.split('\n').length
|
|
139
|
+
const commitsPerDay = totalCommits / days
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
commitsPerDay: Math.max(commitsPerDay, 0.5), // Min 0.5 commits/day
|
|
143
|
+
totalCommits,
|
|
144
|
+
}
|
|
145
|
+
} catch (_error) {
|
|
146
|
+
// Not a git repo or no commits
|
|
147
|
+
return { commitsPerDay: 1, totalCommits: 0 } // Assume 1 commit/day
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Calculate monthly CI costs based on workflow usage
|
|
153
|
+
* @param {Array} workflows - Array of workflow analysis results
|
|
154
|
+
* @param {number} commitsPerDay - Average commits per day
|
|
155
|
+
* @returns {object} Cost breakdown and recommendations
|
|
156
|
+
*/
|
|
157
|
+
function calculateMonthlyCosts(workflows, commitsPerDay) {
|
|
158
|
+
const minutesPerWorkflow = workflows.reduce(
|
|
159
|
+
(total, wf) => total + wf.estimatedDuration,
|
|
160
|
+
0
|
|
161
|
+
)
|
|
162
|
+
const workflowRunsPerDay = commitsPerDay * workflows.length
|
|
163
|
+
const minutesPerDay = minutesPerWorkflow * commitsPerDay
|
|
164
|
+
const minutesPerMonth = Math.ceil(minutesPerDay * 30)
|
|
165
|
+
|
|
166
|
+
// GitHub Actions pricing (as of 2024)
|
|
167
|
+
const FREE_TIER_MINUTES = 2000 // Free tier monthly limit
|
|
168
|
+
const TEAM_TIER_MINUTES = 3000 // Team tier monthly limit
|
|
169
|
+
const COST_PER_MINUTE = 0.008 // $0.008/min for private repos
|
|
170
|
+
|
|
171
|
+
const freeOverage = Math.max(0, minutesPerMonth - FREE_TIER_MINUTES)
|
|
172
|
+
const teamOverage = Math.max(0, minutesPerMonth - TEAM_TIER_MINUTES)
|
|
173
|
+
|
|
174
|
+
const freeOverageCost = freeOverage * COST_PER_MINUTE
|
|
175
|
+
const teamOverageCost = teamOverage * COST_PER_MINUTE
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
minutesPerMonth,
|
|
179
|
+
minutesPerDay,
|
|
180
|
+
workflowRunsPerDay,
|
|
181
|
+
breakdown: workflows.map(wf => ({
|
|
182
|
+
name: wf.name,
|
|
183
|
+
minutesPerRun: wf.estimatedDuration,
|
|
184
|
+
runsPerMonth: Math.ceil(commitsPerDay * 30),
|
|
185
|
+
minutesPerMonth: Math.ceil(wf.estimatedDuration * commitsPerDay * 30),
|
|
186
|
+
})),
|
|
187
|
+
tiers: {
|
|
188
|
+
free: {
|
|
189
|
+
limit: FREE_TIER_MINUTES,
|
|
190
|
+
overage: freeOverage,
|
|
191
|
+
cost: freeOverageCost,
|
|
192
|
+
withinLimit: minutesPerMonth <= FREE_TIER_MINUTES,
|
|
193
|
+
},
|
|
194
|
+
team: {
|
|
195
|
+
limit: TEAM_TIER_MINUTES,
|
|
196
|
+
overage: teamOverage,
|
|
197
|
+
cost: teamOverageCost,
|
|
198
|
+
withinLimit: minutesPerMonth <= TEAM_TIER_MINUTES,
|
|
199
|
+
monthlyCost: 4, // $4/user/month
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Analyze workflows for optimization opportunities
|
|
207
|
+
* @param {Array} workflows - Array of parsed workflow objects
|
|
208
|
+
* @param {number} commitsPerDay - Average commits per day
|
|
209
|
+
* @returns {Array} Array of optimization recommendations
|
|
210
|
+
*/
|
|
211
|
+
function analyzeOptimizations(workflows, commitsPerDay) {
|
|
212
|
+
const recommendations = []
|
|
213
|
+
|
|
214
|
+
for (const wf of workflows) {
|
|
215
|
+
const workflow = wf.parsed
|
|
216
|
+
const workflowName = wf.name
|
|
217
|
+
|
|
218
|
+
if (!workflow.jobs) continue
|
|
219
|
+
|
|
220
|
+
// Check each job for optimization opportunities
|
|
221
|
+
for (const [jobName, job] of Object.entries(workflow.jobs)) {
|
|
222
|
+
// 1. Detect missing caching
|
|
223
|
+
const hasSteps = job.steps && Array.isArray(job.steps)
|
|
224
|
+
if (hasSteps) {
|
|
225
|
+
const hasCaching = job.steps.some(
|
|
226
|
+
step =>
|
|
227
|
+
step.uses &&
|
|
228
|
+
(step.uses.includes('actions/cache') ||
|
|
229
|
+
step.uses.includes('actions/setup-node'))
|
|
230
|
+
)
|
|
231
|
+
const hasInstall = job.steps.some(
|
|
232
|
+
step =>
|
|
233
|
+
step.run &&
|
|
234
|
+
(step.run.includes('npm install') ||
|
|
235
|
+
step.run.includes('yarn install') ||
|
|
236
|
+
step.run.includes('pnpm install') ||
|
|
237
|
+
step.run.includes('pip install'))
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if (hasInstall && !hasCaching) {
|
|
241
|
+
// Estimate 2-5 min savings per run
|
|
242
|
+
const savingsPerRun = 3
|
|
243
|
+
const savingsPerMonth = Math.ceil(savingsPerRun * commitsPerDay * 30)
|
|
244
|
+
|
|
245
|
+
recommendations.push({
|
|
246
|
+
type: 'caching',
|
|
247
|
+
workflow: workflowName,
|
|
248
|
+
job: jobName,
|
|
249
|
+
title: 'Add dependency caching',
|
|
250
|
+
description: `Job "${jobName}" installs dependencies but doesn't cache them`,
|
|
251
|
+
action: 'Add actions/cache before install step',
|
|
252
|
+
potentialSavings: savingsPerMonth,
|
|
253
|
+
savingsPerRun,
|
|
254
|
+
priority: 'high',
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 2. Detect oversized matrix strategies
|
|
260
|
+
if (job.strategy && job.strategy.matrix) {
|
|
261
|
+
const matrixSize = calculateMatrixSize(job.strategy.matrix)
|
|
262
|
+
if (matrixSize >= 6) {
|
|
263
|
+
// Suggest reducing by 50%
|
|
264
|
+
const currentMinutes = wf.estimatedDuration
|
|
265
|
+
const reductionFactor = 0.5
|
|
266
|
+
const savingsPerMonth = Math.ceil(
|
|
267
|
+
currentMinutes * reductionFactor * commitsPerDay * 30
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
recommendations.push({
|
|
271
|
+
type: 'matrix',
|
|
272
|
+
workflow: workflowName,
|
|
273
|
+
job: jobName,
|
|
274
|
+
title: 'Reduce matrix size',
|
|
275
|
+
description: `Job "${jobName}" runs ${matrixSize} matrix combinations`,
|
|
276
|
+
action: `Consider testing only LTS + latest versions (reduce to ${Math.ceil(matrixSize / 2)} combinations)`,
|
|
277
|
+
potentialSavings: savingsPerMonth,
|
|
278
|
+
savingsPerRun: Math.ceil(currentMinutes * reductionFactor),
|
|
279
|
+
priority: matrixSize >= 9 ? 'high' : 'medium',
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 3. Detect high-frequency scheduled workflows
|
|
286
|
+
if (workflow.on) {
|
|
287
|
+
const triggers = Array.isArray(workflow.on) ? workflow.on : [workflow.on]
|
|
288
|
+
const hasSchedule =
|
|
289
|
+
triggers.includes('schedule') ||
|
|
290
|
+
(typeof workflow.on === 'object' && workflow.on.schedule)
|
|
291
|
+
|
|
292
|
+
if (hasSchedule && workflowName.includes('nightly')) {
|
|
293
|
+
const currentRuns = 30 // Daily = 30 runs/month
|
|
294
|
+
const proposedRuns = 4 // Weekly = 4 runs/month
|
|
295
|
+
const savingsPerMonth = Math.ceil(
|
|
296
|
+
wf.estimatedDuration * (currentRuns - proposedRuns)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
recommendations.push({
|
|
300
|
+
type: 'frequency',
|
|
301
|
+
workflow: workflowName,
|
|
302
|
+
title: 'Reduce schedule frequency',
|
|
303
|
+
description: `"${workflowName}" runs nightly (30x/month)`,
|
|
304
|
+
action: 'Change to weekly schedule (4x/month)',
|
|
305
|
+
potentialSavings: savingsPerMonth,
|
|
306
|
+
savingsPerRun: 0,
|
|
307
|
+
priority: savingsPerMonth > 500 ? 'high' : 'medium',
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (hasSchedule && workflowName.includes('weekly')) {
|
|
312
|
+
const currentRuns = 4 // Weekly = 4 runs/month
|
|
313
|
+
const proposedRuns = 1 // Monthly = 1 run/month
|
|
314
|
+
const savingsPerMonth = Math.ceil(
|
|
315
|
+
wf.estimatedDuration * (currentRuns - proposedRuns)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
if (savingsPerMonth > 50) {
|
|
319
|
+
recommendations.push({
|
|
320
|
+
type: 'frequency',
|
|
321
|
+
workflow: workflowName,
|
|
322
|
+
title: 'Reduce schedule frequency',
|
|
323
|
+
description: `"${workflowName}" runs weekly (4x/month)`,
|
|
324
|
+
action: 'Change to monthly schedule (1x/month)',
|
|
325
|
+
potentialSavings: savingsPerMonth,
|
|
326
|
+
savingsPerRun: 0,
|
|
327
|
+
priority: 'low',
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 4. Detect missing path filters
|
|
334
|
+
if (workflow.on && typeof workflow.on === 'object') {
|
|
335
|
+
const hasPush = workflow.on.push || workflow.on.pull_request
|
|
336
|
+
const hasPathFilter =
|
|
337
|
+
(workflow.on.push && workflow.on.push.paths) ||
|
|
338
|
+
(workflow.on.pull_request && workflow.on.pull_request.paths)
|
|
339
|
+
|
|
340
|
+
if (hasPush && !hasPathFilter && !workflowName.includes('release')) {
|
|
341
|
+
// Estimate 20% of commits are docs-only
|
|
342
|
+
const wastedRuns = commitsPerDay * 0.2 * 30
|
|
343
|
+
const savingsPerMonth = Math.ceil(wf.estimatedDuration * wastedRuns)
|
|
344
|
+
|
|
345
|
+
if (savingsPerMonth > 50) {
|
|
346
|
+
recommendations.push({
|
|
347
|
+
type: 'conditional',
|
|
348
|
+
workflow: workflowName,
|
|
349
|
+
title: 'Add path filters',
|
|
350
|
+
description: `"${workflowName}" runs on all commits`,
|
|
351
|
+
action:
|
|
352
|
+
'Skip CI for docs-only changes (paths-ignore: ["**/*.md", "docs/**"])',
|
|
353
|
+
potentialSavings: savingsPerMonth,
|
|
354
|
+
savingsPerRun: 0,
|
|
355
|
+
priority: 'medium',
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Sort by potential savings (highest first)
|
|
363
|
+
recommendations.sort((a, b) => b.potentialSavings - a.potentialSavings)
|
|
364
|
+
|
|
365
|
+
return recommendations
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Generate cost analysis report for terminal output
|
|
370
|
+
* @param {object} analysis - Complete cost analysis results
|
|
371
|
+
*/
|
|
372
|
+
function generateReport(analysis) {
|
|
373
|
+
const { workflows, costs, commitStats, optimizations } = analysis
|
|
374
|
+
|
|
375
|
+
console.log('\nš GitHub Actions Usage Analysis')
|
|
376
|
+
console.log('ā'.repeat(50))
|
|
377
|
+
|
|
378
|
+
// Repository info
|
|
379
|
+
try {
|
|
380
|
+
// Safe: No user input, hardcoded git command
|
|
381
|
+
const remoteUrl = execSync('git remote get-url origin', {
|
|
382
|
+
encoding: 'utf8',
|
|
383
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
384
|
+
}).trim()
|
|
385
|
+
const repoName = remoteUrl.split('/').pop().replace('.git', '')
|
|
386
|
+
console.log(`Repository: ${repoName}`)
|
|
387
|
+
} catch (_error) {
|
|
388
|
+
console.log('Repository: (local)')
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
console.log('')
|
|
392
|
+
|
|
393
|
+
// Usage summary
|
|
394
|
+
console.log(
|
|
395
|
+
`Estimated usage: ${costs.minutesPerMonth.toLocaleString()} min/month`
|
|
396
|
+
)
|
|
397
|
+
console.log(
|
|
398
|
+
` Commit frequency: ~${commitStats.commitsPerDay.toFixed(1)} commits/day`
|
|
399
|
+
)
|
|
400
|
+
console.log(` Workflows detected: ${workflows.length}`)
|
|
401
|
+
console.log('')
|
|
402
|
+
|
|
403
|
+
// Workflow breakdown
|
|
404
|
+
if (costs.breakdown.length > 0) {
|
|
405
|
+
console.log('Workflow breakdown:')
|
|
406
|
+
for (const wf of costs.breakdown) {
|
|
407
|
+
console.log(` āā ${wf.name}:`)
|
|
408
|
+
console.log(` ⢠~${wf.minutesPerRun} min/run`)
|
|
409
|
+
console.log(
|
|
410
|
+
` ⢠~${wf.runsPerMonth} runs/month = ${wf.minutesPerMonth} min/month`
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
console.log('')
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Cost analysis
|
|
417
|
+
console.log('š° Cost Analysis')
|
|
418
|
+
|
|
419
|
+
// Free tier
|
|
420
|
+
if (costs.tiers.free.withinLimit) {
|
|
421
|
+
console.log(
|
|
422
|
+
`Free tier (${costs.tiers.free.limit.toLocaleString()} min): ā
WITHIN LIMIT`
|
|
423
|
+
)
|
|
424
|
+
const remaining = costs.tiers.free.limit - costs.minutesPerMonth
|
|
425
|
+
console.log(` Remaining: ${remaining.toLocaleString()} min/month`)
|
|
426
|
+
} else {
|
|
427
|
+
console.log(
|
|
428
|
+
`Free tier (${costs.tiers.free.limit.toLocaleString()} min): ā ļø EXCEEDED by ${costs.tiers.free.overage.toLocaleString()} min`
|
|
429
|
+
)
|
|
430
|
+
console.log(`Overage cost: $${costs.tiers.free.cost.toFixed(2)}/month`)
|
|
431
|
+
console.log('')
|
|
432
|
+
console.log('Alternative options:')
|
|
433
|
+
|
|
434
|
+
// Team tier comparison
|
|
435
|
+
if (costs.tiers.team.withinLimit) {
|
|
436
|
+
console.log(
|
|
437
|
+
` Team plan ($${costs.tiers.team.monthlyCost}/user/month): ā
Stays within ${costs.tiers.team.limit.toLocaleString()} min limit`
|
|
438
|
+
)
|
|
439
|
+
const savings = costs.tiers.free.cost - costs.tiers.team.monthlyCost
|
|
440
|
+
if (savings > 0) {
|
|
441
|
+
console.log(` Saves $${savings.toFixed(2)}/month per user`)
|
|
442
|
+
}
|
|
443
|
+
} else {
|
|
444
|
+
console.log(
|
|
445
|
+
` Team plan ($${costs.tiers.team.monthlyCost}/user/month): Still exceeds (${costs.tiers.team.overage.toLocaleString()} min overage)`
|
|
446
|
+
)
|
|
447
|
+
console.log(
|
|
448
|
+
` Total cost: $${(costs.tiers.team.monthlyCost + costs.tiers.team.cost).toFixed(2)}/month`
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Self-hosted option
|
|
453
|
+
console.log(' Self-hosted runners: $0/min (but VPS costs ~$5-20/month)')
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
console.log('')
|
|
457
|
+
|
|
458
|
+
// Optimization recommendations
|
|
459
|
+
if (optimizations && optimizations.length > 0) {
|
|
460
|
+
console.log('š” Optimization Recommendations')
|
|
461
|
+
console.log('')
|
|
462
|
+
|
|
463
|
+
const totalPotentialSavings = optimizations.reduce(
|
|
464
|
+
(sum, rec) => sum + rec.potentialSavings,
|
|
465
|
+
0
|
|
466
|
+
)
|
|
467
|
+
const totalSavingsCost = totalPotentialSavings * 0.008
|
|
468
|
+
|
|
469
|
+
console.log(
|
|
470
|
+
`Found ${optimizations.length} optimization opportunities (potential savings: ${totalPotentialSavings.toLocaleString()} min/month = $${totalSavingsCost.toFixed(2)}/month)`
|
|
471
|
+
)
|
|
472
|
+
console.log('')
|
|
473
|
+
|
|
474
|
+
// Group by priority
|
|
475
|
+
const highPriority = optimizations.filter(r => r.priority === 'high')
|
|
476
|
+
const mediumPriority = optimizations.filter(r => r.priority === 'medium')
|
|
477
|
+
const lowPriority = optimizations.filter(r => r.priority === 'low')
|
|
478
|
+
|
|
479
|
+
if (highPriority.length > 0) {
|
|
480
|
+
console.log('š“ High Priority:')
|
|
481
|
+
for (const rec of highPriority) {
|
|
482
|
+
console.log(` āā ${rec.title} (${rec.workflow})`)
|
|
483
|
+
console.log(` ⢠${rec.description}`)
|
|
484
|
+
console.log(` ⢠Action: ${rec.action}`)
|
|
485
|
+
console.log(
|
|
486
|
+
` ⢠Savings: ${rec.potentialSavings.toLocaleString()} min/month ($${(rec.potentialSavings * 0.008).toFixed(2)}/month)`
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
console.log('')
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (mediumPriority.length > 0) {
|
|
493
|
+
console.log('š” Medium Priority:')
|
|
494
|
+
for (const rec of mediumPriority) {
|
|
495
|
+
console.log(` āā ${rec.title} (${rec.workflow})`)
|
|
496
|
+
console.log(` ⢠${rec.description}`)
|
|
497
|
+
console.log(` ⢠Action: ${rec.action}`)
|
|
498
|
+
console.log(
|
|
499
|
+
` ⢠Savings: ${rec.potentialSavings.toLocaleString()} min/month ($${(rec.potentialSavings * 0.008).toFixed(2)}/month)`
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
console.log('')
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (lowPriority.length > 0) {
|
|
506
|
+
console.log('š¢ Low Priority:')
|
|
507
|
+
for (const rec of lowPriority) {
|
|
508
|
+
console.log(` āā ${rec.title} (${rec.workflow})`)
|
|
509
|
+
console.log(` ⢠${rec.description}`)
|
|
510
|
+
console.log(` ⢠Action: ${rec.action}`)
|
|
511
|
+
console.log(
|
|
512
|
+
` ⢠Savings: ${rec.potentialSavings.toLocaleString()} min/month ($${(rec.potentialSavings * 0.008).toFixed(2)}/month)`
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
console.log('')
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
console.log(
|
|
519
|
+
'ā
No optimization opportunities detected - workflows look good!'
|
|
520
|
+
)
|
|
521
|
+
console.log('')
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
console.log('ā'.repeat(50))
|
|
525
|
+
console.log('')
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Main handler for --analyze-ci command
|
|
530
|
+
*/
|
|
531
|
+
async function handleAnalyzeCi() {
|
|
532
|
+
const projectPath = process.cwd()
|
|
533
|
+
|
|
534
|
+
// Check if Pro feature (FREE tier for now during development)
|
|
535
|
+
// TODO: Enable Pro gating after testing
|
|
536
|
+
// const license = getLicenseInfo()
|
|
537
|
+
// if (!hasFeature('ciCostAnalysis')) {
|
|
538
|
+
// showUpgradeMessage('GitHub Actions cost analysis')
|
|
539
|
+
// process.exit(1)
|
|
540
|
+
// }
|
|
541
|
+
|
|
542
|
+
const spinner = showProgress('Analyzing GitHub Actions workflows...')
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
// Step 1: Discover workflows
|
|
546
|
+
const workflowFiles = discoverWorkflows(projectPath)
|
|
547
|
+
|
|
548
|
+
if (workflowFiles.length === 0) {
|
|
549
|
+
spinner.fail('No GitHub Actions workflows found')
|
|
550
|
+
console.log('\nā No .github/workflows directory or workflow files found')
|
|
551
|
+
console.log(
|
|
552
|
+
' Run this command in a repository with GitHub Actions configured'
|
|
553
|
+
)
|
|
554
|
+
process.exit(1)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Step 2: Parse and analyze workflows
|
|
558
|
+
const workflows = []
|
|
559
|
+
for (const wf of workflowFiles) {
|
|
560
|
+
try {
|
|
561
|
+
const content = fs.readFileSync(wf.path, 'utf8')
|
|
562
|
+
const parsed = yaml.load(content)
|
|
563
|
+
|
|
564
|
+
const estimatedDuration = estimateWorkflowDuration(parsed)
|
|
565
|
+
workflows.push({
|
|
566
|
+
name: wf.name,
|
|
567
|
+
path: wf.path,
|
|
568
|
+
estimatedDuration,
|
|
569
|
+
parsed,
|
|
570
|
+
})
|
|
571
|
+
} catch (error) {
|
|
572
|
+
console.warn(`ā ļø Could not parse ${wf.name}: ${error.message}`)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Step 3: Get commit frequency
|
|
577
|
+
const commitStats = getCommitFrequency(projectPath)
|
|
578
|
+
|
|
579
|
+
// Step 4: Calculate costs
|
|
580
|
+
const costs = calculateMonthlyCosts(workflows, commitStats.commitsPerDay)
|
|
581
|
+
|
|
582
|
+
// Step 5: Analyze optimization opportunities
|
|
583
|
+
const optimizations = analyzeOptimizations(
|
|
584
|
+
workflows,
|
|
585
|
+
commitStats.commitsPerDay
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
spinner.succeed('Analysis complete')
|
|
589
|
+
|
|
590
|
+
// Step 6: Generate report
|
|
591
|
+
generateReport({
|
|
592
|
+
workflows,
|
|
593
|
+
costs,
|
|
594
|
+
commitStats,
|
|
595
|
+
optimizations,
|
|
596
|
+
})
|
|
597
|
+
|
|
598
|
+
process.exit(0)
|
|
599
|
+
} catch (error) {
|
|
600
|
+
spinner.fail('Analysis failed')
|
|
601
|
+
console.error(`\nā Error: ${error.message}`)
|
|
602
|
+
if (process.env.DEBUG) {
|
|
603
|
+
console.error(error.stack)
|
|
604
|
+
}
|
|
605
|
+
process.exit(1)
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
module.exports = {
|
|
610
|
+
handleAnalyzeCi,
|
|
611
|
+
discoverWorkflows,
|
|
612
|
+
estimateWorkflowDuration,
|
|
613
|
+
getCommitFrequency,
|
|
614
|
+
calculateMonthlyCosts,
|
|
615
|
+
analyzeOptimizations,
|
|
616
|
+
}
|