@kabran-tecnologia/kabran-config 1.5.0 → 1.7.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.
@@ -0,0 +1,326 @@
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
+ // Trends section (if available)
146
+ if (current.trends && current.trends.data?.total_runs > 1) {
147
+ lines.push(`### 📈 Trends`)
148
+ lines.push('')
149
+ lines.push(`| Metric | Direction | Change (7d) | Avg (30d) |`)
150
+ lines.push(`|--------|-----------|-------------|-----------|`)
151
+
152
+ const scoreTrend = current.trends.score
153
+ if (scoreTrend) {
154
+ const dirEmoji = scoreTrend.direction === 'improving' ? '📈' : scoreTrend.direction === 'degrading' ? '📉' : '➡️'
155
+ lines.push(`| Score | ${dirEmoji} ${scoreTrend.direction} | ${scoreTrend.change_7d !== null ? formatDiff(scoreTrend.change_7d) : 'N/A'} | ${scoreTrend.avg_30d || 'N/A'} |`)
156
+ }
157
+
158
+ const perfTrend = current.trends.performance
159
+ if (perfTrend && perfTrend.avg_30d) {
160
+ const dirEmoji = perfTrend.direction === 'improving' ? '📈' : perfTrend.direction === 'degrading' ? '📉' : '➡️'
161
+ const change7d = perfTrend.change_7d !== null ? `${(perfTrend.change_7d / 1000).toFixed(1)}s` : 'N/A'
162
+ lines.push(`| Duration | ${dirEmoji} ${perfTrend.direction} | ${change7d} | ${(perfTrend.avg_30d / 1000).toFixed(1)}s |`)
163
+ }
164
+
165
+ lines.push('')
166
+ lines.push(`<sub>Based on ${current.trends.data.total_runs} runs</sub>`)
167
+ lines.push('')
168
+ }
169
+
170
+ // Timing info
171
+ lines.push(`---`)
172
+ lines.push(`<sub>`)
173
+ lines.push(`⏱️ Duration: ${current.timing?.total_human || 'N/A'} | `)
174
+ lines.push(`🔀 Branch: ${current.meta?.branch || 'N/A'} | `)
175
+ lines.push(`📝 Commit: ${current.meta?.commit || 'N/A'}`)
176
+
177
+ // Add trace link if available
178
+ if (current.meta?.trace_id) {
179
+ const traceUrl = current.extensions?.telemetry?.trace_url
180
+ if (traceUrl) {
181
+ lines.push(` | 🔍 [Trace](${traceUrl})`)
182
+ } else {
183
+ lines.push(` | 🔍 Trace: ${current.meta.trace_id}`)
184
+ }
185
+ }
186
+
187
+ lines.push(`</sub>`)
188
+
189
+ return lines.join('\n')
190
+ }
191
+
192
+ /**
193
+ * Fetch baseline CI result from another branch
194
+ * @param {string} branch - Branch name
195
+ * @param {string} filePath - Path to ci-result.json in repo
196
+ * @returns {Object|null} CI result or null
197
+ */
198
+ export function fetchBaselineFromBranch(branch, filePath = 'docs/quality/ci-result.json') {
199
+ try {
200
+ const content = execSync(`git show ${branch}:${filePath}`, {
201
+ encoding: 'utf8',
202
+ stdio: ['pipe', 'pipe', 'pipe'],
203
+ })
204
+ return JSON.parse(content)
205
+ } catch {
206
+ return null
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Parse command line arguments
212
+ */
213
+ function parseArgs(args) {
214
+ const options = {
215
+ current: null,
216
+ baseline: null,
217
+ baselineBranch: null,
218
+ output: 'stdout',
219
+ }
220
+
221
+ for (let i = 0; i < args.length; i++) {
222
+ const arg = args[i]
223
+
224
+ if (arg === '--current' || arg === '-c') {
225
+ options.current = args[++i]
226
+ } else if (arg === '--baseline' || arg === '-b') {
227
+ options.baseline = args[++i]
228
+ } else if (arg === '--baseline-branch') {
229
+ options.baselineBranch = args[++i]
230
+ } else if (arg === '--output' || arg === '-o') {
231
+ options.output = args[++i]
232
+ } else if (arg === '--help' || arg === '-h') {
233
+ console.log(`
234
+ Usage: pr-quality-comment.mjs [options]
235
+
236
+ Options:
237
+ -c, --current <file> Current CI result JSON file (required)
238
+ -b, --baseline <file> Baseline CI result JSON file
239
+ --baseline-branch <branch> Fetch baseline from git branch
240
+ -o, --output <file> Output file (default: stdout)
241
+ -h, --help Show this help message
242
+
243
+ Examples:
244
+ # Compare with file
245
+ node pr-quality-comment.mjs -c ci-result.json -b main-ci-result.json
246
+
247
+ # Compare with main branch
248
+ node pr-quality-comment.mjs -c ci-result.json --baseline-branch main
249
+ `)
250
+ process.exit(0)
251
+ }
252
+ }
253
+
254
+ return options
255
+ }
256
+
257
+ /**
258
+ * Main entry point
259
+ */
260
+ async function main() {
261
+ const args = process.argv.slice(2)
262
+ const options = parseArgs(args)
263
+
264
+ // Validate current file
265
+ if (!options.current) {
266
+ console.error('Error: --current is required')
267
+ process.exit(1)
268
+ }
269
+
270
+ if (!existsSync(options.current)) {
271
+ console.error(`Error: Current file not found: ${options.current}`)
272
+ process.exit(1)
273
+ }
274
+
275
+ // Load current result
276
+ const current = JSON.parse(readFileSync(options.current, 'utf8'))
277
+
278
+ // Load baseline result
279
+ let baseline = null
280
+ if (options.baseline) {
281
+ if (!existsSync(options.baseline)) {
282
+ console.error(`Error: Baseline file not found: ${options.baseline}`)
283
+ process.exit(1)
284
+ }
285
+ baseline = JSON.parse(readFileSync(options.baseline, 'utf8'))
286
+ } else if (options.baselineBranch) {
287
+ baseline = fetchBaselineFromBranch(options.baselineBranch)
288
+ if (!baseline) {
289
+ console.error(`Warning: Could not fetch baseline from branch ${options.baselineBranch}`)
290
+ // Create a minimal baseline for first PR
291
+ baseline = {
292
+ summary: { score: 100, total_issues: 0, blocking: 0, status: 'passing' },
293
+ checks: {},
294
+ }
295
+ }
296
+ } else {
297
+ // No baseline provided, create empty comparison
298
+ baseline = {
299
+ summary: { score: 100, total_issues: 0, blocking: 0, status: 'passing' },
300
+ checks: {},
301
+ }
302
+ }
303
+
304
+ // Generate comparison
305
+ const comparison = compareCiResults(current, baseline)
306
+
307
+ // Generate comment
308
+ const comment = generateComment(comparison, current, baseline)
309
+
310
+ // Output
311
+ if (options.output === 'stdout') {
312
+ console.log(comment)
313
+ } else {
314
+ const { writeFileSync } = await import('node:fs')
315
+ writeFileSync(options.output, comment)
316
+ console.log(`Comment written to: ${options.output}`)
317
+ }
318
+ }
319
+
320
+ // Run if called directly
321
+ import { fileURLToPath } from 'node:url'
322
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
323
+ main()
324
+ }
325
+
326
+ export { generateComment as default, fetchBaselineFromBranch }
@@ -79,6 +79,7 @@ export function parseArgs(args) {
79
79
  skipQualityStandard: false,
80
80
  syncWorkflows: false,
81
81
  syncHusky: false,
82
+ telemetryEnv: false,
82
83
  force: false,
83
84
  dryRun: false,
84
85
  help: false,
@@ -97,6 +98,8 @@ export function parseArgs(args) {
97
98
  options.syncWorkflows = true;
98
99
  } else if (arg === '--sync-husky') {
99
100
  options.syncHusky = true;
101
+ } else if (arg === '--telemetry-env') {
102
+ options.telemetryEnv = true;
100
103
  } else if (arg === '--force') {
101
104
  options.force = true;
102
105
  } else if (arg === '--dry-run') {
@@ -132,6 +135,7 @@ ${colors.yellow}OPTIONS:${colors.reset}
132
135
  --skip-quality-standard Don't create quality-standard.md
133
136
  --sync-workflows Overwrite existing workflow files
134
137
  --sync-husky Overwrite existing husky hooks
138
+ --telemetry-env Generate .env.example with telemetry variables
135
139
  --force Overwrite all existing files
136
140
  --dry-run Preview changes without modifying files
137
141
  --help, -h Show this help message
@@ -146,6 +150,9 @@ ${colors.yellow}EXAMPLES:${colors.reset}
146
150
  # Update workflows only
147
151
  npx kabran-setup --sync-workflows
148
152
 
153
+ # Generate telemetry .env.example
154
+ npx kabran-setup --telemetry-env
155
+
149
156
  # Preview changes without modifying
150
157
  npx kabran-setup --dry-run
151
158
 
@@ -547,6 +554,79 @@ export function setupQualityStandard(projectDir, templatesDir, options) {
547
554
  return results;
548
555
  }
549
556
 
557
+ /**
558
+ * Setup telemetry .env.example
559
+ * @param {string} projectDir - Project directory
560
+ * @param {string} templatesDir - Templates directory
561
+ * @param {object} options - Setup options
562
+ * @returns {object} Results
563
+ */
564
+ export function setupTelemetryEnv(projectDir, templatesDir, options) {
565
+ const {force = false, dryRun = false} = options;
566
+
567
+ const results = {
568
+ created: 0,
569
+ overwritten: 0,
570
+ skipped: 0,
571
+ };
572
+
573
+ logInfo('Setting up telemetry .env.example...');
574
+
575
+ const src = join(templatesDir, 'telemetry', '.env.telemetry.example');
576
+ const dest = join(projectDir, '.env.example');
577
+
578
+ // Check if source template exists
579
+ if (!existsSync(src)) {
580
+ logWarn('Template not found: templates/telemetry/.env.telemetry.example');
581
+ return results;
582
+ }
583
+
584
+ // Check if destination exists
585
+ const destExists = existsSync(dest);
586
+
587
+ if (destExists && !force) {
588
+ // If .env.example exists, append telemetry section if not present
589
+ const existingContent = readFileSync(dest, 'utf-8');
590
+
591
+ if (existingContent.includes('Kabran Telemetry Configuration')) {
592
+ if (dryRun) {
593
+ logDry('Would skip .env.example (telemetry section already present)');
594
+ } else {
595
+ logSkip('.env.example (telemetry section already present)');
596
+ }
597
+ results.skipped = 1;
598
+ return results;
599
+ }
600
+
601
+ // Append telemetry section
602
+ const telemetryContent = readFileSync(src, 'utf-8');
603
+
604
+ if (dryRun) {
605
+ logDry('Would append telemetry section to .env.example');
606
+ results.overwritten = 1;
607
+ return results;
608
+ }
609
+
610
+ const newContent = existingContent.trimEnd() + '\n\n' + telemetryContent;
611
+ writeFileSync(dest, newContent, 'utf-8');
612
+ logSuccess('Appended telemetry section to: .env.example');
613
+ results.overwritten = 1;
614
+ return results;
615
+ }
616
+
617
+ const status = copyFile(src, dest, {overwrite: force, dryRun});
618
+
619
+ if (status === 'created' || status === 'would_create') {
620
+ results.created = 1;
621
+ } else if (status === 'overwritten' || status === 'would_overwrite') {
622
+ results.overwritten = 1;
623
+ } else {
624
+ results.skipped = 1;
625
+ }
626
+
627
+ return results;
628
+ }
629
+
550
630
  /**
551
631
  * Run setup
552
632
  * @param {string} projectDir - Project directory
@@ -561,6 +641,7 @@ export function runSetup(projectDir, options) {
561
641
  husky: {created: 0, overwritten: 0, skipped: 0},
562
642
  configs: {created: 0, overwritten: 0, skipped: 0},
563
643
  qualityStandard: {created: 0, overwritten: 0, skipped: 0},
644
+ telemetryEnv: {created: 0, overwritten: 0, skipped: 0},
564
645
  };
565
646
 
566
647
  console.log('');
@@ -592,11 +673,17 @@ export function runSetup(projectDir, options) {
592
673
  }
593
674
 
594
675
  // Setup quality-standard.md (unless skipped or in sync mode)
595
- if (!options.skipQualityStandard && !isSyncMode) {
676
+ if (!options.skipQualityStandard && !isSyncMode && !options.telemetryEnv) {
596
677
  summary.qualityStandard = setupQualityStandard(projectDir, templatesDir, options);
597
678
  console.log('');
598
679
  }
599
680
 
681
+ // Setup telemetry .env.example (only when explicitly requested)
682
+ if (options.telemetryEnv) {
683
+ summary.telemetryEnv = setupTelemetryEnv(projectDir, templatesDir, options);
684
+ console.log('');
685
+ }
686
+
600
687
  return summary;
601
688
  }
602
689
 
@@ -609,10 +696,10 @@ export function printSummary(summary) {
609
696
  console.log(`${colors.cyan}=== Setup Summary ===${colors.reset}`);
610
697
 
611
698
  const total = {
612
- created: summary.workflows.created + summary.husky.created + summary.configs.created + summary.qualityStandard.created,
699
+ created: summary.workflows.created + summary.husky.created + summary.configs.created + summary.qualityStandard.created + summary.telemetryEnv.created,
613
700
  overwritten:
614
- summary.workflows.overwritten + summary.husky.overwritten + summary.configs.overwritten + summary.qualityStandard.overwritten,
615
- skipped: summary.workflows.skipped + summary.husky.skipped + summary.configs.skipped + summary.qualityStandard.skipped,
701
+ summary.workflows.overwritten + summary.husky.overwritten + summary.configs.overwritten + summary.qualityStandard.overwritten + summary.telemetryEnv.overwritten,
702
+ skipped: summary.workflows.skipped + summary.husky.skipped + summary.configs.skipped + summary.qualityStandard.skipped + summary.telemetryEnv.skipped,
616
703
  };
617
704
 
618
705
  if (total.created > 0) {