@kabran-tecnologia/kabran-config 1.5.0 → 1.6.0
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kabran-tecnologia/kabran-config",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Shared quality configurations, enforcement scripts, and CI/CD tooling for Kabran projects",
|
|
5
5
|
"author": "Kabran",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"access": "public"
|
|
10
10
|
},
|
|
11
11
|
"bin": {
|
|
12
|
-
"kabran-setup": "src/scripts/setup.mjs"
|
|
12
|
+
"kabran-setup": "src/scripts/setup.mjs",
|
|
13
|
+
"kabran-ci": "src/scripts/ci/ci-runner.sh",
|
|
14
|
+
"kabran-pr-comment": "src/scripts/pr-quality-comment.mjs"
|
|
13
15
|
},
|
|
14
16
|
"exports": {
|
|
15
17
|
"./eslint": "./src/eslint.mjs",
|
|
@@ -31,6 +33,7 @@
|
|
|
31
33
|
"./scripts/quality-standard-validator": "./src/scripts/quality-standard-validator.mjs",
|
|
32
34
|
"./scripts/generate-ci-result": "./src/scripts/generate-ci-result.mjs",
|
|
33
35
|
"./scripts/ci-result-utils": "./src/scripts/ci-result-utils.mjs",
|
|
36
|
+
"./scripts/pr-quality-comment": "./src/scripts/pr-quality-comment.mjs",
|
|
34
37
|
"./schemas/ci-result": "./src/schemas/ci-result.v2.schema.json"
|
|
35
38
|
},
|
|
36
39
|
"files": [
|
|
@@ -163,6 +163,37 @@ check_dependencies() {
|
|
|
163
163
|
return 0
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
# ==============================================================================
|
|
167
|
+
# Scope Filtering
|
|
168
|
+
# ==============================================================================
|
|
169
|
+
|
|
170
|
+
# Check if a component should be executed based on CI_SCOPE
|
|
171
|
+
# Usage: should_run_component "component_name"
|
|
172
|
+
# Returns: 0 if should run, 1 if should skip
|
|
173
|
+
should_run_component() {
|
|
174
|
+
local component="${1:-}"
|
|
175
|
+
local scope="${CI_SCOPE:-all}"
|
|
176
|
+
|
|
177
|
+
# Always run if scope is "all"
|
|
178
|
+
if [ "$scope" = "all" ]; then
|
|
179
|
+
return 0
|
|
180
|
+
fi
|
|
181
|
+
|
|
182
|
+
# Run if no component specified (global steps)
|
|
183
|
+
if [ -z "$component" ]; then
|
|
184
|
+
return 0
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
# Run if component matches scope
|
|
188
|
+
if [ "$component" = "$scope" ]; then
|
|
189
|
+
return 0
|
|
190
|
+
fi
|
|
191
|
+
|
|
192
|
+
# Skip otherwise
|
|
193
|
+
log_debug "Skipping $component (scope: $scope)"
|
|
194
|
+
return 1
|
|
195
|
+
}
|
|
196
|
+
|
|
166
197
|
# ==============================================================================
|
|
167
198
|
# Step Execution
|
|
168
199
|
# ==============================================================================
|
|
@@ -178,6 +209,29 @@ run_step() {
|
|
|
178
209
|
local category="${5:-custom}"
|
|
179
210
|
local log_file="/tmp/ci_${name}.log"
|
|
180
211
|
|
|
212
|
+
# Check if this step should run based on scope
|
|
213
|
+
if ! should_run_component "$component"; then
|
|
214
|
+
# Record skipped step
|
|
215
|
+
local step_json
|
|
216
|
+
step_json=$(jq -n \
|
|
217
|
+
--arg name "$name" \
|
|
218
|
+
--arg component "$component" \
|
|
219
|
+
--arg category "$category" \
|
|
220
|
+
'{
|
|
221
|
+
name: $name,
|
|
222
|
+
status: "skip",
|
|
223
|
+
exit_code: 0,
|
|
224
|
+
duration_ms: 0,
|
|
225
|
+
duration_human: "0ms",
|
|
226
|
+
category: $category,
|
|
227
|
+
skip_reason: "scope_filter"
|
|
228
|
+
} + (if $component != "" then {component: $component} else {} end)'
|
|
229
|
+
)
|
|
230
|
+
STEP_RESULTS+=("$step_json")
|
|
231
|
+
log_info "Skipping: $name (out of scope)"
|
|
232
|
+
return 0
|
|
233
|
+
fi
|
|
234
|
+
|
|
181
235
|
# Capture start time (milliseconds since epoch)
|
|
182
236
|
local start_time
|
|
183
237
|
start_time=$(date +%s%3N 2>/dev/null || echo $(($(date +%s) * 1000)))
|
|
@@ -375,6 +429,9 @@ export_ci_data() {
|
|
|
375
429
|
# Create output directory
|
|
376
430
|
mkdir -p "$(dirname "$output_file")"
|
|
377
431
|
|
|
432
|
+
# Get scope
|
|
433
|
+
local scope="${CI_SCOPE:-all}"
|
|
434
|
+
|
|
378
435
|
# Generate intermediate data file for Node.js generator
|
|
379
436
|
jq -n \
|
|
380
437
|
--argjson steps "$steps_json" \
|
|
@@ -383,6 +440,7 @@ export_ci_data() {
|
|
|
383
440
|
--arg started_at "$started_at" \
|
|
384
441
|
--arg finished_at "$now" \
|
|
385
442
|
--arg project_name "$project_name" \
|
|
443
|
+
--arg scope "$scope" \
|
|
386
444
|
'{
|
|
387
445
|
steps: $steps,
|
|
388
446
|
errors: $errors,
|
|
@@ -393,6 +451,9 @@ export_ci_data() {
|
|
|
393
451
|
},
|
|
394
452
|
project: {
|
|
395
453
|
name: $project_name
|
|
454
|
+
},
|
|
455
|
+
metadata: {
|
|
456
|
+
scope: $scope
|
|
396
457
|
}
|
|
397
458
|
}' > "$output_file"
|
|
398
459
|
|
|
@@ -27,6 +27,7 @@ OUTPUT_FILE_V2="${CI_OUTPUT_FILE_V2:-docs/quality/ci-result.json}"
|
|
|
27
27
|
VERBOSE="${CI_VERBOSE:-false}"
|
|
28
28
|
PROJECT_ROOT="${PROJECT_ROOT:-$(pwd)}"
|
|
29
29
|
USE_V2="${CI_USE_V2:-true}"
|
|
30
|
+
CI_SCOPE="${CI_SCOPE:-all}" # Scope filter: "all" or component name (e.g., "app", "website")
|
|
30
31
|
|
|
31
32
|
# Track errors (reinitialize to avoid issues with sourced scripts)
|
|
32
33
|
ERRORS=()
|
|
@@ -80,6 +81,9 @@ fi
|
|
|
80
81
|
log_info "Starting Kabran CI - $PROJECT_NAME"
|
|
81
82
|
log_info "Working directory: $PROJECT_ROOT"
|
|
82
83
|
log_info "CI Core Version: $CI_CORE_VERSION"
|
|
84
|
+
if [ "$CI_SCOPE" != "all" ]; then
|
|
85
|
+
log_info "Scope: $CI_SCOPE (filtered)"
|
|
86
|
+
fi
|
|
83
87
|
echo ""
|
|
84
88
|
|
|
85
89
|
# Start timing
|
|
@@ -305,6 +305,125 @@ export function extractComponents(steps) {
|
|
|
305
305
|
return Array.from(components).sort()
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Aggregate coverage data from multiple test steps
|
|
310
|
+
*
|
|
311
|
+
* @param {Array} steps - Array of step results with coverage data
|
|
312
|
+
* @returns {Object|null} Aggregated coverage or null if no coverage data
|
|
313
|
+
*/
|
|
314
|
+
export function aggregateCoverage(steps) {
|
|
315
|
+
const coverageData = []
|
|
316
|
+
|
|
317
|
+
for (const step of steps) {
|
|
318
|
+
if (step.output?.coverage) {
|
|
319
|
+
coverageData.push({
|
|
320
|
+
component: step.component || 'default',
|
|
321
|
+
coverage: step.output.coverage,
|
|
322
|
+
tests: {
|
|
323
|
+
passed: step.output.passed || 0,
|
|
324
|
+
failed: step.output.failed || 0,
|
|
325
|
+
skipped: step.output.skipped || 0,
|
|
326
|
+
},
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (coverageData.length === 0) {
|
|
332
|
+
return null
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Calculate weighted average based on test count
|
|
336
|
+
let totalTests = 0
|
|
337
|
+
let weightedLines = 0
|
|
338
|
+
let weightedBranches = 0
|
|
339
|
+
let weightedFunctions = 0
|
|
340
|
+
let weightedStatements = 0
|
|
341
|
+
|
|
342
|
+
const byComponent = {}
|
|
343
|
+
|
|
344
|
+
for (const data of coverageData) {
|
|
345
|
+
const testCount = data.tests.passed + data.tests.failed
|
|
346
|
+
totalTests += testCount
|
|
347
|
+
|
|
348
|
+
const cov = data.coverage
|
|
349
|
+
if (cov.lines !== undefined) weightedLines += cov.lines * testCount
|
|
350
|
+
if (cov.branches !== undefined) weightedBranches += cov.branches * testCount
|
|
351
|
+
if (cov.functions !== undefined) weightedFunctions += cov.functions * testCount
|
|
352
|
+
if (cov.statements !== undefined) weightedStatements += cov.statements * testCount
|
|
353
|
+
|
|
354
|
+
byComponent[data.component] = {
|
|
355
|
+
...data.coverage,
|
|
356
|
+
tests: data.tests,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Calculate averages
|
|
361
|
+
const avgLines = totalTests > 0 ? Math.round((weightedLines / totalTests) * 10) / 10 : 0
|
|
362
|
+
const avgBranches = totalTests > 0 ? Math.round((weightedBranches / totalTests) * 10) / 10 : 0
|
|
363
|
+
const avgFunctions = totalTests > 0 ? Math.round((weightedFunctions / totalTests) * 10) / 10 : 0
|
|
364
|
+
const avgStatements = totalTests > 0 ? Math.round((weightedStatements / totalTests) * 10) / 10 : 0
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
lines: avgLines,
|
|
368
|
+
branches: avgBranches,
|
|
369
|
+
functions: avgFunctions,
|
|
370
|
+
statements: avgStatements,
|
|
371
|
+
by_component: byComponent,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Compare two CI results and calculate diff
|
|
377
|
+
*
|
|
378
|
+
* @param {Object} current - Current CI result
|
|
379
|
+
* @param {Object} baseline - Baseline CI result (e.g., from main branch)
|
|
380
|
+
* @returns {Object} Comparison result with diffs
|
|
381
|
+
*/
|
|
382
|
+
export function compareCiResults(current, baseline) {
|
|
383
|
+
const scoreDiff = current.summary.score - baseline.summary.score
|
|
384
|
+
const issuesDiff = current.summary.total_issues - baseline.summary.total_issues
|
|
385
|
+
const blockingDiff = current.summary.blocking - baseline.summary.blocking
|
|
386
|
+
|
|
387
|
+
// Determine trend
|
|
388
|
+
let trend = 'stable'
|
|
389
|
+
if (scoreDiff > 5) trend = 'improving'
|
|
390
|
+
else if (scoreDiff < -5) trend = 'degrading'
|
|
391
|
+
|
|
392
|
+
// Compare coverage if available
|
|
393
|
+
let coverageDiff = null
|
|
394
|
+
if (current.checks?.test?.coverage && baseline.checks?.test?.coverage) {
|
|
395
|
+
coverageDiff = {
|
|
396
|
+
lines: (current.checks.test.coverage.lines || 0) - (baseline.checks.test.coverage.lines || 0),
|
|
397
|
+
branches: (current.checks.test.coverage.branches || 0) - (baseline.checks.test.coverage.branches || 0),
|
|
398
|
+
functions: (current.checks.test.coverage.functions || 0) - (baseline.checks.test.coverage.functions || 0),
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
trend,
|
|
404
|
+
score: {
|
|
405
|
+
current: current.summary.score,
|
|
406
|
+
baseline: baseline.summary.score,
|
|
407
|
+
diff: scoreDiff,
|
|
408
|
+
},
|
|
409
|
+
issues: {
|
|
410
|
+
current: current.summary.total_issues,
|
|
411
|
+
baseline: baseline.summary.total_issues,
|
|
412
|
+
diff: issuesDiff,
|
|
413
|
+
},
|
|
414
|
+
blocking: {
|
|
415
|
+
current: current.summary.blocking,
|
|
416
|
+
baseline: baseline.summary.blocking,
|
|
417
|
+
diff: blockingDiff,
|
|
418
|
+
},
|
|
419
|
+
coverage: coverageDiff,
|
|
420
|
+
status: {
|
|
421
|
+
current: current.summary.status,
|
|
422
|
+
baseline: baseline.summary.status,
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
308
427
|
/**
|
|
309
428
|
* Create a minimal valid CI result object
|
|
310
429
|
*
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PR Quality Comment Generator
|
|
5
|
+
*
|
|
6
|
+
* Generates a formatted PR comment comparing CI results between
|
|
7
|
+
* the current branch and a baseline (typically main).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node pr-quality-comment.mjs --current ci-result.json --baseline main-ci-result.json
|
|
11
|
+
* node pr-quality-comment.mjs --current ci-result.json --baseline-branch main
|
|
12
|
+
*
|
|
13
|
+
* Output: Markdown formatted comment to stdout
|
|
14
|
+
*
|
|
15
|
+
* @module pr-quality-comment
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
19
|
+
import { execSync } from 'node:child_process'
|
|
20
|
+
import { compareCiResults } from './ci-result-utils.mjs'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get trend emoji
|
|
24
|
+
* @param {'improving'|'stable'|'degrading'} trend
|
|
25
|
+
* @returns {string} Emoji
|
|
26
|
+
*/
|
|
27
|
+
function getTrendEmoji(trend) {
|
|
28
|
+
switch (trend) {
|
|
29
|
+
case 'improving': return '📈'
|
|
30
|
+
case 'degrading': return '📉'
|
|
31
|
+
default: return '➡️'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get status emoji
|
|
37
|
+
* @param {'passing'|'degraded'|'failing'} status
|
|
38
|
+
* @returns {string} Emoji
|
|
39
|
+
*/
|
|
40
|
+
function getStatusEmoji(status) {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case 'passing': return '✅'
|
|
43
|
+
case 'degraded': return '⚠️'
|
|
44
|
+
case 'failing': return '❌'
|
|
45
|
+
default: return '❓'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format diff with sign and color indicator
|
|
51
|
+
* @param {number} diff - Difference value
|
|
52
|
+
* @param {boolean} inverted - Whether positive is bad (for issues)
|
|
53
|
+
* @returns {string} Formatted diff string
|
|
54
|
+
*/
|
|
55
|
+
function formatDiff(diff, inverted = false) {
|
|
56
|
+
if (diff === 0) return '='
|
|
57
|
+
const sign = diff > 0 ? '+' : ''
|
|
58
|
+
const indicator = inverted
|
|
59
|
+
? (diff > 0 ? '🔴' : '🟢')
|
|
60
|
+
: (diff > 0 ? '🟢' : '🔴')
|
|
61
|
+
return `${sign}${diff} ${indicator}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate markdown comment from comparison
|
|
66
|
+
* @param {Object} comparison - Comparison result from compareCiResults
|
|
67
|
+
* @param {Object} current - Current CI result
|
|
68
|
+
* @param {Object} baseline - Baseline CI result
|
|
69
|
+
* @returns {string} Markdown formatted comment
|
|
70
|
+
*/
|
|
71
|
+
export function generateComment(comparison, current, baseline) {
|
|
72
|
+
const lines = []
|
|
73
|
+
|
|
74
|
+
// Header
|
|
75
|
+
lines.push(`## ${getTrendEmoji(comparison.trend)} Quality Report`)
|
|
76
|
+
lines.push('')
|
|
77
|
+
|
|
78
|
+
// Status summary
|
|
79
|
+
const currentStatus = `${getStatusEmoji(comparison.status.current)} ${comparison.status.current}`
|
|
80
|
+
const baselineStatus = `${getStatusEmoji(comparison.status.baseline)} ${comparison.status.baseline}`
|
|
81
|
+
|
|
82
|
+
lines.push(`| Metric | Current | Baseline | Diff |`)
|
|
83
|
+
lines.push(`|--------|---------|----------|------|`)
|
|
84
|
+
lines.push(`| **Status** | ${currentStatus} | ${baselineStatus} | - |`)
|
|
85
|
+
lines.push(`| **Score** | ${comparison.score.current} | ${comparison.score.baseline} | ${formatDiff(comparison.score.diff)} |`)
|
|
86
|
+
lines.push(`| **Issues** | ${comparison.issues.current} | ${comparison.issues.baseline} | ${formatDiff(comparison.issues.diff, true)} |`)
|
|
87
|
+
lines.push(`| **Blocking** | ${comparison.blocking.current} | ${comparison.blocking.baseline} | ${formatDiff(comparison.blocking.diff, true)} |`)
|
|
88
|
+
lines.push('')
|
|
89
|
+
|
|
90
|
+
// Coverage section (if available)
|
|
91
|
+
if (comparison.coverage) {
|
|
92
|
+
lines.push(`### 📊 Coverage`)
|
|
93
|
+
lines.push('')
|
|
94
|
+
lines.push(`| Type | Current | Baseline | Diff |`)
|
|
95
|
+
lines.push(`|------|---------|----------|------|`)
|
|
96
|
+
|
|
97
|
+
const currentCov = current.checks?.test?.coverage || {}
|
|
98
|
+
const baselineCov = baseline.checks?.test?.coverage || {}
|
|
99
|
+
|
|
100
|
+
if (comparison.coverage.lines !== null) {
|
|
101
|
+
lines.push(`| Lines | ${currentCov.lines || 0}% | ${baselineCov.lines || 0}% | ${formatDiff(comparison.coverage.lines)} |`)
|
|
102
|
+
}
|
|
103
|
+
if (comparison.coverage.branches !== null) {
|
|
104
|
+
lines.push(`| Branches | ${currentCov.branches || 0}% | ${baselineCov.branches || 0}% | ${formatDiff(comparison.coverage.branches)} |`)
|
|
105
|
+
}
|
|
106
|
+
if (comparison.coverage.functions !== null) {
|
|
107
|
+
lines.push(`| Functions | ${currentCov.functions || 0}% | ${baselineCov.functions || 0}% | ${formatDiff(comparison.coverage.functions)} |`)
|
|
108
|
+
}
|
|
109
|
+
lines.push('')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Checks summary
|
|
113
|
+
if (current.checks && Object.keys(current.checks).length > 0) {
|
|
114
|
+
lines.push(`### 🔍 Checks`)
|
|
115
|
+
lines.push('')
|
|
116
|
+
lines.push(`| Check | Status | Errors | Warnings |`)
|
|
117
|
+
lines.push(`|-------|--------|--------|----------|`)
|
|
118
|
+
|
|
119
|
+
for (const [name, check] of Object.entries(current.checks)) {
|
|
120
|
+
const emoji = getStatusEmoji(check.status)
|
|
121
|
+
lines.push(`| ${name} | ${emoji} ${check.status} | ${check.errors || 0} | ${check.warnings || 0} |`)
|
|
122
|
+
}
|
|
123
|
+
lines.push('')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// New issues (if any)
|
|
127
|
+
if (current.issues && current.issues.length > 0) {
|
|
128
|
+
const newIssues = current.issues.filter(i => i.severity === 'error')
|
|
129
|
+
if (newIssues.length > 0) {
|
|
130
|
+
lines.push(`### ⚠️ Blocking Issues`)
|
|
131
|
+
lines.push('')
|
|
132
|
+
for (const issue of newIssues.slice(0, 5)) {
|
|
133
|
+
lines.push(`- \`${issue.rule || issue.id}\`: ${issue.message}`)
|
|
134
|
+
if (issue.file) {
|
|
135
|
+
lines.push(` - 📁 ${issue.file}${issue.line ? `:${issue.line}` : ''}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (newIssues.length > 5) {
|
|
139
|
+
lines.push(`- ... and ${newIssues.length - 5} more`)
|
|
140
|
+
}
|
|
141
|
+
lines.push('')
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Timing info
|
|
146
|
+
lines.push(`---`)
|
|
147
|
+
lines.push(`<sub>`)
|
|
148
|
+
lines.push(`⏱️ Duration: ${current.timing?.total_human || 'N/A'} | `)
|
|
149
|
+
lines.push(`🔀 Branch: ${current.meta?.branch || 'N/A'} | `)
|
|
150
|
+
lines.push(`📝 Commit: ${current.meta?.commit || 'N/A'}`)
|
|
151
|
+
lines.push(`</sub>`)
|
|
152
|
+
|
|
153
|
+
return lines.join('\n')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Fetch baseline CI result from another branch
|
|
158
|
+
* @param {string} branch - Branch name
|
|
159
|
+
* @param {string} filePath - Path to ci-result.json in repo
|
|
160
|
+
* @returns {Object|null} CI result or null
|
|
161
|
+
*/
|
|
162
|
+
export function fetchBaselineFromBranch(branch, filePath = 'docs/quality/ci-result.json') {
|
|
163
|
+
try {
|
|
164
|
+
const content = execSync(`git show ${branch}:${filePath}`, {
|
|
165
|
+
encoding: 'utf8',
|
|
166
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
167
|
+
})
|
|
168
|
+
return JSON.parse(content)
|
|
169
|
+
} catch {
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse command line arguments
|
|
176
|
+
*/
|
|
177
|
+
function parseArgs(args) {
|
|
178
|
+
const options = {
|
|
179
|
+
current: null,
|
|
180
|
+
baseline: null,
|
|
181
|
+
baselineBranch: null,
|
|
182
|
+
output: 'stdout',
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < args.length; i++) {
|
|
186
|
+
const arg = args[i]
|
|
187
|
+
|
|
188
|
+
if (arg === '--current' || arg === '-c') {
|
|
189
|
+
options.current = args[++i]
|
|
190
|
+
} else if (arg === '--baseline' || arg === '-b') {
|
|
191
|
+
options.baseline = args[++i]
|
|
192
|
+
} else if (arg === '--baseline-branch') {
|
|
193
|
+
options.baselineBranch = args[++i]
|
|
194
|
+
} else if (arg === '--output' || arg === '-o') {
|
|
195
|
+
options.output = args[++i]
|
|
196
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
197
|
+
console.log(`
|
|
198
|
+
Usage: pr-quality-comment.mjs [options]
|
|
199
|
+
|
|
200
|
+
Options:
|
|
201
|
+
-c, --current <file> Current CI result JSON file (required)
|
|
202
|
+
-b, --baseline <file> Baseline CI result JSON file
|
|
203
|
+
--baseline-branch <branch> Fetch baseline from git branch
|
|
204
|
+
-o, --output <file> Output file (default: stdout)
|
|
205
|
+
-h, --help Show this help message
|
|
206
|
+
|
|
207
|
+
Examples:
|
|
208
|
+
# Compare with file
|
|
209
|
+
node pr-quality-comment.mjs -c ci-result.json -b main-ci-result.json
|
|
210
|
+
|
|
211
|
+
# Compare with main branch
|
|
212
|
+
node pr-quality-comment.mjs -c ci-result.json --baseline-branch main
|
|
213
|
+
`)
|
|
214
|
+
process.exit(0)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return options
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Main entry point
|
|
223
|
+
*/
|
|
224
|
+
async function main() {
|
|
225
|
+
const args = process.argv.slice(2)
|
|
226
|
+
const options = parseArgs(args)
|
|
227
|
+
|
|
228
|
+
// Validate current file
|
|
229
|
+
if (!options.current) {
|
|
230
|
+
console.error('Error: --current is required')
|
|
231
|
+
process.exit(1)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!existsSync(options.current)) {
|
|
235
|
+
console.error(`Error: Current file not found: ${options.current}`)
|
|
236
|
+
process.exit(1)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Load current result
|
|
240
|
+
const current = JSON.parse(readFileSync(options.current, 'utf8'))
|
|
241
|
+
|
|
242
|
+
// Load baseline result
|
|
243
|
+
let baseline = null
|
|
244
|
+
if (options.baseline) {
|
|
245
|
+
if (!existsSync(options.baseline)) {
|
|
246
|
+
console.error(`Error: Baseline file not found: ${options.baseline}`)
|
|
247
|
+
process.exit(1)
|
|
248
|
+
}
|
|
249
|
+
baseline = JSON.parse(readFileSync(options.baseline, 'utf8'))
|
|
250
|
+
} else if (options.baselineBranch) {
|
|
251
|
+
baseline = fetchBaselineFromBranch(options.baselineBranch)
|
|
252
|
+
if (!baseline) {
|
|
253
|
+
console.error(`Warning: Could not fetch baseline from branch ${options.baselineBranch}`)
|
|
254
|
+
// Create a minimal baseline for first PR
|
|
255
|
+
baseline = {
|
|
256
|
+
summary: { score: 100, total_issues: 0, blocking: 0, status: 'passing' },
|
|
257
|
+
checks: {},
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
// No baseline provided, create empty comparison
|
|
262
|
+
baseline = {
|
|
263
|
+
summary: { score: 100, total_issues: 0, blocking: 0, status: 'passing' },
|
|
264
|
+
checks: {},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Generate comparison
|
|
269
|
+
const comparison = compareCiResults(current, baseline)
|
|
270
|
+
|
|
271
|
+
// Generate comment
|
|
272
|
+
const comment = generateComment(comparison, current, baseline)
|
|
273
|
+
|
|
274
|
+
// Output
|
|
275
|
+
if (options.output === 'stdout') {
|
|
276
|
+
console.log(comment)
|
|
277
|
+
} else {
|
|
278
|
+
const { writeFileSync } = await import('node:fs')
|
|
279
|
+
writeFileSync(options.output, comment)
|
|
280
|
+
console.log(`Comment written to: ${options.output}`)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Run if called directly
|
|
285
|
+
import { fileURLToPath } from 'node:url'
|
|
286
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
287
|
+
main()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export { generateComment as default, fetchBaselineFromBranch }
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Kabran CI Quality Workflow
|
|
2
|
+
#
|
|
3
|
+
# This workflow runs the Kabran CI pipeline and posts quality reports to PRs.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# Copy this file to your project's .github/workflows/ directory.
|
|
7
|
+
# Customize the ci-config.sh to define your project's CI steps.
|
|
8
|
+
#
|
|
9
|
+
# Requirements:
|
|
10
|
+
# - scripts/ci-config.sh with PROJECT_NAME, PM, and ci_steps() defined
|
|
11
|
+
# - @kabran-tecnologia/kabran-config installed as dev dependency
|
|
12
|
+
|
|
13
|
+
name: CI Quality
|
|
14
|
+
|
|
15
|
+
on:
|
|
16
|
+
push:
|
|
17
|
+
branches: [main]
|
|
18
|
+
pull_request:
|
|
19
|
+
branches: [main]
|
|
20
|
+
|
|
21
|
+
# Cancel in-progress runs for the same branch
|
|
22
|
+
concurrency:
|
|
23
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
24
|
+
cancel-in-progress: true
|
|
25
|
+
|
|
26
|
+
jobs:
|
|
27
|
+
ci:
|
|
28
|
+
name: CI Pipeline
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
|
|
31
|
+
permissions:
|
|
32
|
+
contents: read
|
|
33
|
+
pull-requests: write # Required for PR comments
|
|
34
|
+
|
|
35
|
+
steps:
|
|
36
|
+
- name: Checkout
|
|
37
|
+
uses: actions/checkout@v4
|
|
38
|
+
with:
|
|
39
|
+
fetch-depth: 0 # Full history for baseline comparison
|
|
40
|
+
|
|
41
|
+
- name: Setup Node.js
|
|
42
|
+
uses: actions/setup-node@v4
|
|
43
|
+
with:
|
|
44
|
+
node-version: '20'
|
|
45
|
+
cache: 'npm'
|
|
46
|
+
|
|
47
|
+
- name: Install dependencies
|
|
48
|
+
run: npm ci
|
|
49
|
+
|
|
50
|
+
- name: Run CI Pipeline
|
|
51
|
+
id: ci
|
|
52
|
+
env:
|
|
53
|
+
CI_USE_V2: 'true'
|
|
54
|
+
CI_OUTPUT_FILE_V2: 'docs/quality/ci-result.json'
|
|
55
|
+
# CI_SCOPE: 'all' # Uncomment and set to filter by component
|
|
56
|
+
run: |
|
|
57
|
+
# Run CI using kabran-config runner
|
|
58
|
+
npx kabran-ci || exit_code=$?
|
|
59
|
+
|
|
60
|
+
# Ensure ci-result.json exists
|
|
61
|
+
if [ -f "docs/quality/ci-result.json" ]; then
|
|
62
|
+
echo "ci_result_exists=true" >> $GITHUB_OUTPUT
|
|
63
|
+
else
|
|
64
|
+
echo "ci_result_exists=false" >> $GITHUB_OUTPUT
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
exit ${exit_code:-0}
|
|
68
|
+
|
|
69
|
+
- name: Generate PR Comment
|
|
70
|
+
if: github.event_name == 'pull_request' && steps.ci.outputs.ci_result_exists == 'true'
|
|
71
|
+
id: comment
|
|
72
|
+
run: |
|
|
73
|
+
# Generate quality comparison comment
|
|
74
|
+
npx kabran-pr-comment \
|
|
75
|
+
--current docs/quality/ci-result.json \
|
|
76
|
+
--baseline-branch ${{ github.base_ref }} \
|
|
77
|
+
--output /tmp/pr-comment.md || true
|
|
78
|
+
|
|
79
|
+
if [ -f "/tmp/pr-comment.md" ]; then
|
|
80
|
+
echo "comment_exists=true" >> $GITHUB_OUTPUT
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
- name: Post PR Comment
|
|
84
|
+
if: github.event_name == 'pull_request' && steps.comment.outputs.comment_exists == 'true'
|
|
85
|
+
uses: marocchino/sticky-pull-request-comment@v2
|
|
86
|
+
with:
|
|
87
|
+
header: kabran-quality
|
|
88
|
+
path: /tmp/pr-comment.md
|
|
89
|
+
|
|
90
|
+
- name: Upload CI Result
|
|
91
|
+
if: always() && steps.ci.outputs.ci_result_exists == 'true'
|
|
92
|
+
uses: actions/upload-artifact@v4
|
|
93
|
+
with:
|
|
94
|
+
name: ci-result
|
|
95
|
+
path: docs/quality/ci-result.json
|
|
96
|
+
retention-days: 30
|
|
97
|
+
|
|
98
|
+
- name: Commit CI Result (main only)
|
|
99
|
+
if: github.ref == 'refs/heads/main' && github.event_name == 'push' && steps.ci.outputs.ci_result_exists == 'true'
|
|
100
|
+
run: |
|
|
101
|
+
git config user.name "github-actions[bot]"
|
|
102
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
103
|
+
|
|
104
|
+
# Only commit if there are changes
|
|
105
|
+
if git diff --quiet docs/quality/ci-result.json; then
|
|
106
|
+
echo "No changes to ci-result.json"
|
|
107
|
+
else
|
|
108
|
+
git add docs/quality/ci-result.json
|
|
109
|
+
git commit -m "chore(ci): update ci-result.json [skip ci]"
|
|
110
|
+
git push
|
|
111
|
+
fi
|