@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.5.0",
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