@kabran-tecnologia/kabran-config 1.6.0 → 1.8.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/README.md +283 -0
- package/package.json +63 -8
- package/src/schemas/ci-result.v2.schema.json +125 -0
- package/src/scripts/ci/ci-core.sh +131 -1
- package/src/scripts/ci/ci-runner.sh +88 -0
- package/src/scripts/ci-result-history.mjs +245 -0
- package/src/scripts/ci-result-trends.mjs +296 -0
- package/src/scripts/ci-result-utils.mjs +104 -0
- package/src/scripts/generate-ci-result.mjs +92 -11
- package/src/scripts/pr-quality-comment.mjs +36 -0
- package/src/scripts/setup.mjs +91 -4
- package/src/telemetry/README.md +407 -0
- package/src/telemetry/config/defaults.mjs +421 -0
- package/src/telemetry/config/index.mjs +132 -0
- package/src/telemetry/edge/index.mjs +446 -0
- package/src/telemetry/frontend/index.mjs +366 -0
- package/src/telemetry/logger/index.mjs +236 -0
- package/src/telemetry/node/index.mjs +386 -0
- package/src/telemetry/shared/helpers.mjs +133 -0
- package/src/telemetry/shared/index.mjs +15 -0
- package/src/telemetry/shared/types.d.ts +123 -0
- package/templates/telemetry/.env.telemetry.example +118 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI Result Trends Module
|
|
3
|
+
*
|
|
4
|
+
* Calculates trends from historical CI run data.
|
|
5
|
+
* Analyzes score, performance, and issues over time.
|
|
6
|
+
*
|
|
7
|
+
* @module ci-result-trends
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { filterByDays, filterByBranch } from './ci-result-history.mjs'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Direction indicators for trends
|
|
14
|
+
*/
|
|
15
|
+
export const DIRECTION = {
|
|
16
|
+
IMPROVING: 'improving',
|
|
17
|
+
DEGRADING: 'degrading',
|
|
18
|
+
STABLE: 'stable',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Threshold for determining significant change (percentage)
|
|
23
|
+
*/
|
|
24
|
+
export const SIGNIFICANCE_THRESHOLD = 5
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Calculate average of numeric values
|
|
28
|
+
*
|
|
29
|
+
* @param {number[]} values - Array of numbers
|
|
30
|
+
* @returns {number} Average value
|
|
31
|
+
*/
|
|
32
|
+
export function calculateAverage(values) {
|
|
33
|
+
if (!values || values.length === 0) return 0
|
|
34
|
+
const sum = values.reduce((acc, val) => acc + (val || 0), 0)
|
|
35
|
+
return sum / values.length
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Calculate percentage change between two values
|
|
40
|
+
*
|
|
41
|
+
* @param {number} oldValue - Previous value
|
|
42
|
+
* @param {number} newValue - Current value
|
|
43
|
+
* @returns {number} Percentage change (null if cannot calculate)
|
|
44
|
+
*/
|
|
45
|
+
export function calculatePercentageChange(oldValue, newValue) {
|
|
46
|
+
if (oldValue === 0) {
|
|
47
|
+
if (newValue === 0) return 0
|
|
48
|
+
return newValue > 0 ? 100 : -100
|
|
49
|
+
}
|
|
50
|
+
return ((newValue - oldValue) / Math.abs(oldValue)) * 100
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Calculate absolute change between two values
|
|
55
|
+
*
|
|
56
|
+
* @param {number} oldValue - Previous value
|
|
57
|
+
* @param {number} newValue - Current value
|
|
58
|
+
* @returns {number} Absolute change
|
|
59
|
+
*/
|
|
60
|
+
export function calculateAbsoluteChange(oldValue, newValue) {
|
|
61
|
+
return newValue - oldValue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Determine direction of trend
|
|
66
|
+
*
|
|
67
|
+
* @param {number} changePercent - Percentage change
|
|
68
|
+
* @param {number} threshold - Significance threshold
|
|
69
|
+
* @param {boolean} higherIsBetter - Whether higher values are better
|
|
70
|
+
* @returns {string} Direction: 'improving', 'degrading', or 'stable'
|
|
71
|
+
*/
|
|
72
|
+
export function determineDirection(changePercent, threshold = SIGNIFICANCE_THRESHOLD, higherIsBetter = true) {
|
|
73
|
+
if (Math.abs(changePercent) < threshold) {
|
|
74
|
+
return DIRECTION.STABLE
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isPositive = changePercent > 0
|
|
78
|
+
|
|
79
|
+
if (higherIsBetter) {
|
|
80
|
+
return isPositive ? DIRECTION.IMPROVING : DIRECTION.DEGRADING
|
|
81
|
+
} else {
|
|
82
|
+
return isPositive ? DIRECTION.DEGRADING : DIRECTION.IMPROVING
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract field values from history entries
|
|
88
|
+
*
|
|
89
|
+
* @param {Object[]} entries - History entries
|
|
90
|
+
* @param {string} field - Field path (dot notation supported)
|
|
91
|
+
* @returns {number[]} Array of values
|
|
92
|
+
*/
|
|
93
|
+
export function extractValues(entries, field) {
|
|
94
|
+
return entries.map((entry) => {
|
|
95
|
+
const parts = field.split('.')
|
|
96
|
+
let value = entry
|
|
97
|
+
for (const part of parts) {
|
|
98
|
+
value = value?.[part]
|
|
99
|
+
}
|
|
100
|
+
return typeof value === 'number' ? value : 0
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Calculate trend for a specific metric
|
|
106
|
+
*
|
|
107
|
+
* @param {Object[]} entries - History entries (newest first)
|
|
108
|
+
* @param {string} field - Field path to analyze
|
|
109
|
+
* @param {boolean} higherIsBetter - Whether higher values are better
|
|
110
|
+
* @returns {Object} Trend object with direction and changes
|
|
111
|
+
*/
|
|
112
|
+
export function calculateMetricTrend(entries, field, higherIsBetter = true) {
|
|
113
|
+
if (!entries || entries.length === 0) {
|
|
114
|
+
return {
|
|
115
|
+
direction: DIRECTION.STABLE,
|
|
116
|
+
current: null,
|
|
117
|
+
change_7d: null,
|
|
118
|
+
change_30d: null,
|
|
119
|
+
avg_7d: null,
|
|
120
|
+
avg_30d: null,
|
|
121
|
+
data_points: 0,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const values = extractValues(entries, field)
|
|
126
|
+
const current = values[0] || 0
|
|
127
|
+
|
|
128
|
+
// Get entries by time period
|
|
129
|
+
const entries7d = filterByDays(entries, 7)
|
|
130
|
+
const entries30d = filterByDays(entries, 30)
|
|
131
|
+
|
|
132
|
+
const values7d = extractValues(entries7d, field)
|
|
133
|
+
const values30d = extractValues(entries30d, field)
|
|
134
|
+
|
|
135
|
+
const avg7d = calculateAverage(values7d)
|
|
136
|
+
const avg30d = calculateAverage(values30d)
|
|
137
|
+
|
|
138
|
+
// Calculate changes
|
|
139
|
+
const change7d = values7d.length > 1 ? calculateAbsoluteChange(values7d[values7d.length - 1], current) : null
|
|
140
|
+
|
|
141
|
+
const change30d = values30d.length > 1 ? calculateAbsoluteChange(values30d[values30d.length - 1], current) : null
|
|
142
|
+
|
|
143
|
+
// Determine direction based on 7d vs 30d averages
|
|
144
|
+
let direction = DIRECTION.STABLE
|
|
145
|
+
if (avg30d !== 0 && values7d.length >= 2 && values30d.length >= 2) {
|
|
146
|
+
const percentChange = calculatePercentageChange(avg30d, avg7d)
|
|
147
|
+
direction = determineDirection(percentChange, SIGNIFICANCE_THRESHOLD, higherIsBetter)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
direction,
|
|
152
|
+
current: Math.round(current * 100) / 100,
|
|
153
|
+
change_7d: change7d !== null ? Math.round(change7d * 100) / 100 : null,
|
|
154
|
+
change_30d: change30d !== null ? Math.round(change30d * 100) / 100 : null,
|
|
155
|
+
avg_7d: Math.round(avg7d * 100) / 100,
|
|
156
|
+
avg_30d: Math.round(avg30d * 100) / 100,
|
|
157
|
+
data_points: entries.length,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Calculate all trends from history entries
|
|
163
|
+
*
|
|
164
|
+
* @param {Object[]} entries - History entries (newest first)
|
|
165
|
+
* @param {string} [branch] - Optional branch filter
|
|
166
|
+
* @returns {Object} Complete trends object
|
|
167
|
+
*/
|
|
168
|
+
export function calculateTrends(entries, branch = null) {
|
|
169
|
+
// Filter by branch if specified
|
|
170
|
+
const filteredEntries = branch ? filterByBranch(entries, branch) : entries
|
|
171
|
+
|
|
172
|
+
if (!filteredEntries || filteredEntries.length === 0) {
|
|
173
|
+
return {
|
|
174
|
+
score: createEmptyTrend(),
|
|
175
|
+
performance: createEmptyTrend(),
|
|
176
|
+
issues: createEmptyTrend(),
|
|
177
|
+
data: {
|
|
178
|
+
total_runs: 0,
|
|
179
|
+
branch_filter: branch,
|
|
180
|
+
calculated_at: new Date().toISOString(),
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
score: calculateMetricTrend(filteredEntries, 'score', true),
|
|
187
|
+
performance: calculateMetricTrend(filteredEntries, 'timing.total_ms', false),
|
|
188
|
+
issues: calculateMetricTrend(filteredEntries, 'summary.total_issues', false),
|
|
189
|
+
data: {
|
|
190
|
+
total_runs: filteredEntries.length,
|
|
191
|
+
branch_filter: branch,
|
|
192
|
+
calculated_at: new Date().toISOString(),
|
|
193
|
+
date_range: {
|
|
194
|
+
from: filteredEntries[filteredEntries.length - 1]?.generated_at,
|
|
195
|
+
to: filteredEntries[0]?.generated_at,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Create an empty trend object
|
|
203
|
+
*
|
|
204
|
+
* @returns {Object} Empty trend structure
|
|
205
|
+
*/
|
|
206
|
+
function createEmptyTrend() {
|
|
207
|
+
return {
|
|
208
|
+
direction: DIRECTION.STABLE,
|
|
209
|
+
current: null,
|
|
210
|
+
change_7d: null,
|
|
211
|
+
change_30d: null,
|
|
212
|
+
avg_7d: null,
|
|
213
|
+
avg_30d: null,
|
|
214
|
+
data_points: 0,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Calculate trends grouped by branch
|
|
220
|
+
*
|
|
221
|
+
* @param {Object[]} entries - History entries
|
|
222
|
+
* @returns {Object} Trends by branch
|
|
223
|
+
*/
|
|
224
|
+
export function calculateTrendsByBranch(entries) {
|
|
225
|
+
if (!entries || entries.length === 0) {
|
|
226
|
+
return {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const branches = [...new Set(entries.map((e) => e.branch).filter(Boolean))]
|
|
230
|
+
const result = {}
|
|
231
|
+
|
|
232
|
+
for (const branch of branches) {
|
|
233
|
+
result[branch] = calculateTrends(entries, branch)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return result
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Generate time series data for visualization
|
|
241
|
+
*
|
|
242
|
+
* @param {Object[]} entries - History entries
|
|
243
|
+
* @param {string} field - Field to extract
|
|
244
|
+
* @param {number} [limit] - Maximum data points
|
|
245
|
+
* @returns {Object[]} Time series array
|
|
246
|
+
*/
|
|
247
|
+
export function generateTimeSeries(entries, field, limit = 30) {
|
|
248
|
+
if (!entries || entries.length === 0) return []
|
|
249
|
+
|
|
250
|
+
const values = extractValues(entries, field)
|
|
251
|
+
|
|
252
|
+
return entries.slice(0, limit).map((entry, index) => ({
|
|
253
|
+
date: entry.generated_at,
|
|
254
|
+
value: values[index],
|
|
255
|
+
run_id: entry.run_id,
|
|
256
|
+
branch: entry.branch,
|
|
257
|
+
}))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Calculate summary statistics for trend display
|
|
262
|
+
*
|
|
263
|
+
* @param {Object} trends - Trends object from calculateTrends
|
|
264
|
+
* @returns {Object} Summary for display
|
|
265
|
+
*/
|
|
266
|
+
export function getTrendSummary(trends) {
|
|
267
|
+
const summaryLines = []
|
|
268
|
+
|
|
269
|
+
if (trends.score.direction !== DIRECTION.STABLE) {
|
|
270
|
+
const emoji = trends.score.direction === DIRECTION.IMPROVING ? '+' : '-'
|
|
271
|
+
const change = trends.score.change_7d !== null ? Math.abs(trends.score.change_7d) : 0
|
|
272
|
+
summaryLines.push(`Score ${trends.score.direction} (${emoji}${change} over 7d)`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (trends.performance.direction !== DIRECTION.STABLE) {
|
|
276
|
+
const emoji = trends.performance.direction === DIRECTION.IMPROVING ? '-' : '+'
|
|
277
|
+
const change = trends.performance.change_7d !== null ? Math.abs(trends.performance.change_7d / 1000) : 0
|
|
278
|
+
summaryLines.push(`Performance ${trends.performance.direction} (${emoji}${change.toFixed(1)}s over 7d)`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (trends.issues.direction !== DIRECTION.STABLE) {
|
|
282
|
+
const emoji = trends.issues.direction === DIRECTION.IMPROVING ? '-' : '+'
|
|
283
|
+
const change = trends.issues.change_7d !== null ? Math.abs(trends.issues.change_7d) : 0
|
|
284
|
+
summaryLines.push(`Issues ${trends.issues.direction} (${emoji}${change} over 7d)`)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
lines: summaryLines,
|
|
289
|
+
overall:
|
|
290
|
+
summaryLines.length === 0
|
|
291
|
+
? 'stable'
|
|
292
|
+
: summaryLines.some((l) => l.includes('degrading'))
|
|
293
|
+
? 'degrading'
|
|
294
|
+
: 'improving',
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -482,3 +482,107 @@ export function createMinimalResult({ projectName, passed = true }) {
|
|
|
482
482
|
extensions: {},
|
|
483
483
|
}
|
|
484
484
|
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Get trace ID from environment variables
|
|
488
|
+
*
|
|
489
|
+
* Checks common OpenTelemetry and CI environment variables for trace IDs.
|
|
490
|
+
*
|
|
491
|
+
* @returns {string|null} Trace ID or null if not available
|
|
492
|
+
*/
|
|
493
|
+
export function getTraceId() {
|
|
494
|
+
// Direct trace ID from environment
|
|
495
|
+
if (process.env.OTEL_TRACE_ID) {
|
|
496
|
+
return process.env.OTEL_TRACE_ID
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// W3C traceparent header format: 00-{trace_id}-{span_id}-{flags}
|
|
500
|
+
if (process.env.TRACEPARENT) {
|
|
501
|
+
const parts = process.env.TRACEPARENT.split('-')
|
|
502
|
+
if (parts.length >= 2) {
|
|
503
|
+
return parts[1]
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// GitHub Actions run ID as fallback trace correlation
|
|
508
|
+
if (process.env.GITHUB_RUN_ID) {
|
|
509
|
+
return `gh-${process.env.GITHUB_RUN_ID}`
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return null
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Build trace URL from trace ID and endpoint
|
|
517
|
+
*
|
|
518
|
+
* @param {string} traceId - The trace ID
|
|
519
|
+
* @param {string} [endpoint] - OTel endpoint (default: from env)
|
|
520
|
+
* @param {string} [template] - URL template with {trace_id} placeholder
|
|
521
|
+
* @returns {string|null} Trace URL or null
|
|
522
|
+
*/
|
|
523
|
+
export function buildTraceUrl(traceId, endpoint = null, template = null) {
|
|
524
|
+
if (!traceId) return null
|
|
525
|
+
|
|
526
|
+
const otelEndpoint = endpoint || process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.OTEL_ENDPOINT
|
|
527
|
+
|
|
528
|
+
// Use custom template if provided
|
|
529
|
+
if (template) {
|
|
530
|
+
return template.replace('{trace_id}', traceId).replace('{endpoint}', otelEndpoint || '')
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Use env template if available
|
|
534
|
+
if (process.env.OTEL_TRACE_URL_TEMPLATE) {
|
|
535
|
+
return process.env.OTEL_TRACE_URL_TEMPLATE.replace('{trace_id}', traceId).replace(
|
|
536
|
+
'{endpoint}',
|
|
537
|
+
otelEndpoint || ''
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Default Jaeger-style URL if endpoint is known
|
|
542
|
+
if (otelEndpoint) {
|
|
543
|
+
// Extract base URL from endpoint (remove /v1/traces if present)
|
|
544
|
+
const baseUrl = otelEndpoint.replace(/\/v1\/traces\/?$/, '').replace(/\/$/, '')
|
|
545
|
+
return `${baseUrl}/trace/${traceId}`
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return null
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Build telemetry extension object for ci-result.json
|
|
553
|
+
*
|
|
554
|
+
* @param {string} traceId - The trace ID
|
|
555
|
+
* @param {Object} [options] - Additional options
|
|
556
|
+
* @param {number} [options.spansExported] - Number of spans exported
|
|
557
|
+
* @param {number} [options.errorsRecorded] - Number of errors recorded
|
|
558
|
+
* @param {string} [options.endpoint] - OTel endpoint
|
|
559
|
+
* @returns {Object} Telemetry extension object
|
|
560
|
+
*/
|
|
561
|
+
export function buildTelemetryExtension(traceId, options = {}) {
|
|
562
|
+
const { spansExported = 0, errorsRecorded = 0, endpoint = null } = options
|
|
563
|
+
|
|
564
|
+
const extension = {
|
|
565
|
+
trace_id: traceId,
|
|
566
|
+
enabled: true,
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Add trace URL if we can build one
|
|
570
|
+
const traceUrl = buildTraceUrl(traceId, endpoint)
|
|
571
|
+
if (traceUrl) {
|
|
572
|
+
extension.trace_url = traceUrl
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Add metrics if provided
|
|
576
|
+
if (spansExported > 0 || errorsRecorded > 0) {
|
|
577
|
+
extension.spans_exported = spansExported
|
|
578
|
+
extension.errors_recorded = errorsRecorded
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Add endpoint info
|
|
582
|
+
const otelEndpoint = endpoint || process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.OTEL_ENDPOINT
|
|
583
|
+
if (otelEndpoint) {
|
|
584
|
+
extension.collector_endpoint = otelEndpoint
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return extension
|
|
588
|
+
}
|
|
@@ -34,8 +34,14 @@ import {
|
|
|
34
34
|
calculateExecutionStats,
|
|
35
35
|
countIssues,
|
|
36
36
|
extractComponents,
|
|
37
|
+
getTraceId,
|
|
38
|
+
buildTelemetryExtension,
|
|
37
39
|
} from './ci-result-utils.mjs'
|
|
38
40
|
|
|
41
|
+
// History and trends imports
|
|
42
|
+
import { loadHistory, addToHistory, saveHistory } from './ci-result-history.mjs'
|
|
43
|
+
import { calculateTrends } from './ci-result-trends.mjs'
|
|
44
|
+
|
|
39
45
|
// Validator imports
|
|
40
46
|
import { getLicenseCheckResult } from './license-check.mjs'
|
|
41
47
|
import { getReadmeCheckResult } from './readme-validator.mjs'
|
|
@@ -132,6 +138,7 @@ async function runValidators(projectRoot, options = {}) {
|
|
|
132
138
|
* @param {Object} input.project - Project information
|
|
133
139
|
* @param {Object} input.metadata - Additional metadata
|
|
134
140
|
* @param {Object} input.validators - Validator results (from runValidators)
|
|
141
|
+
* @param {Object} input.trends - Pre-calculated trends (optional)
|
|
135
142
|
* @returns {Object} CI result object
|
|
136
143
|
*/
|
|
137
144
|
export function generateCiResult(input) {
|
|
@@ -143,6 +150,7 @@ export function generateCiResult(input) {
|
|
|
143
150
|
metadata = {},
|
|
144
151
|
issues = [],
|
|
145
152
|
validators = {},
|
|
153
|
+
trends = null,
|
|
146
154
|
} = input
|
|
147
155
|
|
|
148
156
|
const now = new Date().toISOString()
|
|
@@ -169,19 +177,49 @@ export function generateCiResult(input) {
|
|
|
169
177
|
// Determine exit code
|
|
170
178
|
const exitCode = executionStats.steps_failed > 0 ? 1 : 0
|
|
171
179
|
|
|
180
|
+
// Get trace ID - prefer trace_context from input (shell-generated) over env vars
|
|
181
|
+
const traceContext = input.trace_context || {}
|
|
182
|
+
const traceId = traceContext.trace_id || getTraceId()
|
|
183
|
+
|
|
184
|
+
// Build meta object
|
|
185
|
+
const meta = {
|
|
186
|
+
generated_at: now,
|
|
187
|
+
generator: getGeneratorVersion(),
|
|
188
|
+
run_id: generateRunId(),
|
|
189
|
+
trigger: detectTrigger(),
|
|
190
|
+
branch: getGitBranch(),
|
|
191
|
+
commit: getGitCommit(),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Add trace_id if available
|
|
195
|
+
if (traceId) {
|
|
196
|
+
meta.trace_id = traceId
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Build extensions with telemetry if trace_id exists
|
|
200
|
+
const extensions = { ...(metadata.extensions || {}) }
|
|
201
|
+
if (traceId) {
|
|
202
|
+
// Count errors from failed steps
|
|
203
|
+
const errorsRecorded = executionStats.steps_failed || 0
|
|
204
|
+
|
|
205
|
+
extensions.telemetry = buildTelemetryExtension(traceId, {
|
|
206
|
+
errorsRecorded,
|
|
207
|
+
// spans_exported remains 0 until we implement actual OTel export (GAP-004/Q12)
|
|
208
|
+
spansExported: 0,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Add trace source info if available
|
|
212
|
+
if (traceContext.source) {
|
|
213
|
+
extensions.telemetry.trace_source = traceContext.source
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
172
217
|
// Build result object
|
|
173
218
|
const result = {
|
|
174
219
|
$schema: 'https://kabran.dev/schemas/ci-result.v2.json',
|
|
175
220
|
version: '1.0.0',
|
|
176
221
|
|
|
177
|
-
meta
|
|
178
|
-
generated_at: now,
|
|
179
|
-
generator: getGeneratorVersion(),
|
|
180
|
-
run_id: generateRunId(),
|
|
181
|
-
trigger: detectTrigger(),
|
|
182
|
-
branch: getGitBranch(),
|
|
183
|
-
commit: getGitCommit(),
|
|
184
|
-
},
|
|
222
|
+
meta,
|
|
185
223
|
|
|
186
224
|
project: {
|
|
187
225
|
name: project.name || 'unknown',
|
|
@@ -216,7 +254,12 @@ export function generateCiResult(input) {
|
|
|
216
254
|
checks,
|
|
217
255
|
issues,
|
|
218
256
|
errors,
|
|
219
|
-
extensions
|
|
257
|
+
extensions,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Add trends if provided
|
|
261
|
+
if (trends) {
|
|
262
|
+
result.trends = trends
|
|
220
263
|
}
|
|
221
264
|
|
|
222
265
|
return result
|
|
@@ -236,6 +279,10 @@ function parseArgs(args) {
|
|
|
236
279
|
skipReadme: false,
|
|
237
280
|
skipEnv: false,
|
|
238
281
|
skipQualityStandard: false,
|
|
282
|
+
trackHistory: false,
|
|
283
|
+
historyFile: null,
|
|
284
|
+
maxHistoryEntries: 30,
|
|
285
|
+
calculateTrends: false,
|
|
239
286
|
}
|
|
240
287
|
|
|
241
288
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -259,6 +306,16 @@ function parseArgs(args) {
|
|
|
259
306
|
options.skipEnv = true
|
|
260
307
|
} else if (arg === '--skip-quality-standard') {
|
|
261
308
|
options.skipQualityStandard = true
|
|
309
|
+
} else if (arg === '--track-history') {
|
|
310
|
+
options.trackHistory = true
|
|
311
|
+
} else if (arg === '--history-file') {
|
|
312
|
+
options.historyFile = args[++i]
|
|
313
|
+
options.trackHistory = true
|
|
314
|
+
} else if (arg === '--max-history') {
|
|
315
|
+
options.maxHistoryEntries = parseInt(args[++i], 10) || 30
|
|
316
|
+
} else if (arg === '--calculate-trends') {
|
|
317
|
+
options.calculateTrends = true
|
|
318
|
+
options.trackHistory = true
|
|
262
319
|
} else if (arg === '--help' || arg === '-h') {
|
|
263
320
|
console.log(`
|
|
264
321
|
Usage: generate-ci-result.mjs [options]
|
|
@@ -273,6 +330,10 @@ Options:
|
|
|
273
330
|
--skip-readme Skip README check when running validators
|
|
274
331
|
--skip-env Skip env check when running validators
|
|
275
332
|
--skip-quality-standard Skip quality-standard check when running validators
|
|
333
|
+
--track-history Track result in history file (max 30 entries)
|
|
334
|
+
--history-file <file> Custom history file path (default: docs/quality/ci-result-history.json)
|
|
335
|
+
--max-history <n> Maximum history entries to keep (default: 30)
|
|
336
|
+
--calculate-trends Calculate and include trends from history
|
|
276
337
|
-h, --help Show this help message
|
|
277
338
|
|
|
278
339
|
Input format:
|
|
@@ -362,15 +423,35 @@ async function main() {
|
|
|
362
423
|
})
|
|
363
424
|
}
|
|
364
425
|
|
|
426
|
+
// Determine paths
|
|
427
|
+
const outputPath = options.output || resolve(options.projectRoot, 'docs/quality/ci-result.json')
|
|
428
|
+
const historyPath = options.historyFile || resolve(options.projectRoot, 'docs/quality/ci-result-history.json')
|
|
429
|
+
|
|
430
|
+
// Load history and calculate trends if requested
|
|
431
|
+
let history = null
|
|
432
|
+
if (options.trackHistory || options.calculateTrends) {
|
|
433
|
+
history = loadHistory(historyPath)
|
|
434
|
+
|
|
435
|
+
// Calculate trends if requested
|
|
436
|
+
if (options.calculateTrends && history.entries.length > 0) {
|
|
437
|
+
input.trends = calculateTrends(history.entries)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
365
441
|
// Generate result
|
|
366
442
|
const result = generateCiResult(input)
|
|
367
443
|
|
|
444
|
+
// Update history with new result
|
|
445
|
+
if (options.trackHistory && history) {
|
|
446
|
+
addToHistory(result, history)
|
|
447
|
+
saveHistory(history, historyPath, options.maxHistoryEntries)
|
|
448
|
+
console.log(`History updated: ${historyPath} (${history.entries.length} entries)`)
|
|
449
|
+
}
|
|
450
|
+
|
|
368
451
|
// Output
|
|
369
452
|
if (options.stdout) {
|
|
370
453
|
console.log(JSON.stringify(result, null, 2))
|
|
371
454
|
} else {
|
|
372
|
-
const outputPath = options.output || resolve(options.projectRoot, 'docs/quality/ci-result.json')
|
|
373
|
-
|
|
374
455
|
// Ensure directory exists
|
|
375
456
|
mkdirSync(dirname(outputPath), { recursive: true })
|
|
376
457
|
|
|
@@ -142,12 +142,48 @@ export function generateComment(comparison, current, baseline) {
|
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
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
|
+
|
|
145
170
|
// Timing info
|
|
146
171
|
lines.push(`---`)
|
|
147
172
|
lines.push(`<sub>`)
|
|
148
173
|
lines.push(`⏱️ Duration: ${current.timing?.total_human || 'N/A'} | `)
|
|
149
174
|
lines.push(`🔀 Branch: ${current.meta?.branch || 'N/A'} | `)
|
|
150
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
|
+
|
|
151
187
|
lines.push(`</sub>`)
|
|
152
188
|
|
|
153
189
|
return lines.join('\n')
|