@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.
- package/README.md +283 -0
- package/package.json +67 -9
- package/src/schemas/ci-result.v2.schema.json +125 -0
- package/src/scripts/ci/ci-core.sh +61 -0
- package/src/scripts/ci/ci-runner.sh +89 -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 +223 -0
- package/src/scripts/generate-ci-result.mjs +79 -11
- package/src/scripts/pr-quality-comment.mjs +326 -0
- package/src/scripts/setup.mjs +91 -4
- 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/.github/workflows/ci-quality.yml +111 -0
- package/templates/telemetry/.env.telemetry.example +118 -0
|
@@ -10,6 +10,64 @@ set -euo pipefail
|
|
|
10
10
|
RUNNER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
11
|
CORE_SCRIPT="$RUNNER_DIR/ci-core.sh"
|
|
12
12
|
|
|
13
|
+
# ==============================================================================
|
|
14
|
+
# Command Line Arguments
|
|
15
|
+
# ==============================================================================
|
|
16
|
+
|
|
17
|
+
show_help() {
|
|
18
|
+
cat << EOF
|
|
19
|
+
Usage: $(basename "$0") [options]
|
|
20
|
+
|
|
21
|
+
Kabran CI Runner - Execute project CI pipelines
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--list-scopes List available scopes from ci-config.sh and exit
|
|
25
|
+
--scope <name> Run only the specified scope (component)
|
|
26
|
+
--help Show this help message and exit
|
|
27
|
+
|
|
28
|
+
Environment Variables:
|
|
29
|
+
CI_SCOPE Set the scope filter (default: all)
|
|
30
|
+
CI_VERBOSE Enable verbose output (default: false)
|
|
31
|
+
CI_OUTPUT_FILE Output file for legacy v1 format
|
|
32
|
+
CI_OUTPUT_FILE_V2 Output file for v2 format (default: docs/quality/ci-result.json)
|
|
33
|
+
CI_CONFIG_FILE Path to project ci-config.sh
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
# Run all steps
|
|
37
|
+
$(basename "$0")
|
|
38
|
+
|
|
39
|
+
# Run only specific component
|
|
40
|
+
$(basename "$0") --scope app
|
|
41
|
+
|
|
42
|
+
# List available scopes
|
|
43
|
+
$(basename "$0") --list-scopes
|
|
44
|
+
EOF
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
LIST_SCOPES=false
|
|
48
|
+
|
|
49
|
+
while [[ $# -gt 0 ]]; do
|
|
50
|
+
case $1 in
|
|
51
|
+
--list-scopes)
|
|
52
|
+
LIST_SCOPES=true
|
|
53
|
+
shift
|
|
54
|
+
;;
|
|
55
|
+
--scope)
|
|
56
|
+
CI_SCOPE="$2"
|
|
57
|
+
shift 2
|
|
58
|
+
;;
|
|
59
|
+
--help|-h)
|
|
60
|
+
show_help
|
|
61
|
+
exit 0
|
|
62
|
+
;;
|
|
63
|
+
*)
|
|
64
|
+
echo "Unknown option: $1" >&2
|
|
65
|
+
show_help
|
|
66
|
+
exit 1
|
|
67
|
+
;;
|
|
68
|
+
esac
|
|
69
|
+
done
|
|
70
|
+
|
|
13
71
|
# Load core functions
|
|
14
72
|
if [ ! -f "$CORE_SCRIPT" ]; then
|
|
15
73
|
echo "ERROR: ci-core.sh not found at $CORE_SCRIPT" >&2
|
|
@@ -27,6 +85,7 @@ OUTPUT_FILE_V2="${CI_OUTPUT_FILE_V2:-docs/quality/ci-result.json}"
|
|
|
27
85
|
VERBOSE="${CI_VERBOSE:-false}"
|
|
28
86
|
PROJECT_ROOT="${PROJECT_ROOT:-$(pwd)}"
|
|
29
87
|
USE_V2="${CI_USE_V2:-true}"
|
|
88
|
+
CI_SCOPE="${CI_SCOPE:-all}" # Scope filter: "all" or component name (e.g., "app", "website")
|
|
30
89
|
|
|
31
90
|
# Track errors (reinitialize to avoid issues with sourced scripts)
|
|
32
91
|
ERRORS=()
|
|
@@ -57,6 +116,33 @@ fi
|
|
|
57
116
|
log_info "Loading project configuration: $CONFIG_FILE"
|
|
58
117
|
source "$CONFIG_FILE"
|
|
59
118
|
|
|
119
|
+
# ==============================================================================
|
|
120
|
+
# List Scopes (if requested)
|
|
121
|
+
# ==============================================================================
|
|
122
|
+
|
|
123
|
+
if [ "$LIST_SCOPES" = "true" ]; then
|
|
124
|
+
echo "Available scopes in $PROJECT_NAME:"
|
|
125
|
+
echo ""
|
|
126
|
+
|
|
127
|
+
# Check if CI_COMPONENTS is defined (common pattern for monorepos)
|
|
128
|
+
if [ -n "${CI_COMPONENTS:-}" ]; then
|
|
129
|
+
echo "Components:"
|
|
130
|
+
for component in $CI_COMPONENTS; do
|
|
131
|
+
echo " - $component"
|
|
132
|
+
done
|
|
133
|
+
elif declare -f list_scopes >/dev/null; then
|
|
134
|
+
# Project can define custom list_scopes function
|
|
135
|
+
list_scopes
|
|
136
|
+
else
|
|
137
|
+
echo " all (single project - no components defined)"
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
echo ""
|
|
141
|
+
echo "Usage: CI_SCOPE=<scope> $(basename "$0")"
|
|
142
|
+
echo " or: $(basename "$0") --scope <scope>"
|
|
143
|
+
exit 0
|
|
144
|
+
fi
|
|
145
|
+
|
|
60
146
|
# ==============================================================================
|
|
61
147
|
# Validate Configuration
|
|
62
148
|
# ==============================================================================
|
|
@@ -80,6 +166,9 @@ fi
|
|
|
80
166
|
log_info "Starting Kabran CI - $PROJECT_NAME"
|
|
81
167
|
log_info "Working directory: $PROJECT_ROOT"
|
|
82
168
|
log_info "CI Core Version: $CI_CORE_VERSION"
|
|
169
|
+
if [ "$CI_SCOPE" != "all" ]; then
|
|
170
|
+
log_info "Scope: $CI_SCOPE (filtered)"
|
|
171
|
+
fi
|
|
83
172
|
echo ""
|
|
84
173
|
|
|
85
174
|
# Start timing
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CI Result History Module
|
|
3
|
+
*
|
|
4
|
+
* Manages historical CI run data for trend analysis.
|
|
5
|
+
* Stores up to 30 recent runs in docs/quality/ci-result-history.json.
|
|
6
|
+
*
|
|
7
|
+
* @module ci-result-history
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
11
|
+
import { dirname } from 'node:path'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default maximum number of history entries to keep
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_MAX_ENTRIES = 30
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract a minimal history entry from a full CI result
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} result - Full CI result object
|
|
22
|
+
* @returns {Object} Minimal history entry
|
|
23
|
+
*/
|
|
24
|
+
export function extractHistoryEntry(result) {
|
|
25
|
+
return {
|
|
26
|
+
run_id: result.meta?.run_id || 'unknown',
|
|
27
|
+
generated_at: result.meta?.generated_at || new Date().toISOString(),
|
|
28
|
+
branch: result.meta?.branch || 'unknown',
|
|
29
|
+
commit: result.meta?.commit || 'unknown',
|
|
30
|
+
score: result.summary?.score ?? 0,
|
|
31
|
+
status: result.summary?.status || 'unknown',
|
|
32
|
+
timing: {
|
|
33
|
+
total_ms: result.timing?.total_ms || 0,
|
|
34
|
+
total_human: result.timing?.total_human || '0ms',
|
|
35
|
+
},
|
|
36
|
+
summary: {
|
|
37
|
+
exit_code: result.summary?.exit_code ?? 0,
|
|
38
|
+
total_issues: result.summary?.total_issues ?? 0,
|
|
39
|
+
blocking: result.summary?.blocking ?? 0,
|
|
40
|
+
warnings: result.summary?.warnings ?? 0,
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load history from file
|
|
47
|
+
*
|
|
48
|
+
* @param {string} filePath - Path to history file
|
|
49
|
+
* @returns {Object} History object with entries array and metadata
|
|
50
|
+
*/
|
|
51
|
+
export function loadHistory(filePath) {
|
|
52
|
+
if (!existsSync(filePath)) {
|
|
53
|
+
return {
|
|
54
|
+
$schema: 'https://kabran.dev/schemas/ci-result-history.json',
|
|
55
|
+
version: '1.0.0',
|
|
56
|
+
meta: {
|
|
57
|
+
created_at: new Date().toISOString(),
|
|
58
|
+
updated_at: new Date().toISOString(),
|
|
59
|
+
max_entries: DEFAULT_MAX_ENTRIES,
|
|
60
|
+
},
|
|
61
|
+
entries: [],
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const content = readFileSync(filePath, 'utf8')
|
|
67
|
+
const history = JSON.parse(content)
|
|
68
|
+
|
|
69
|
+
// Ensure entries is an array
|
|
70
|
+
if (!Array.isArray(history.entries)) {
|
|
71
|
+
history.entries = []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return history
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// If file is corrupted, start fresh
|
|
77
|
+
return {
|
|
78
|
+
$schema: 'https://kabran.dev/schemas/ci-result-history.json',
|
|
79
|
+
version: '1.0.0',
|
|
80
|
+
meta: {
|
|
81
|
+
created_at: new Date().toISOString(),
|
|
82
|
+
updated_at: new Date().toISOString(),
|
|
83
|
+
max_entries: DEFAULT_MAX_ENTRIES,
|
|
84
|
+
error: `Failed to load: ${error.message}`,
|
|
85
|
+
},
|
|
86
|
+
entries: [],
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Prune history to maximum number of entries
|
|
93
|
+
*
|
|
94
|
+
* @param {Array} entries - History entries
|
|
95
|
+
* @param {number} maxEntries - Maximum entries to keep
|
|
96
|
+
* @returns {Array} Pruned entries (most recent first)
|
|
97
|
+
*/
|
|
98
|
+
export function pruneHistory(entries, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
99
|
+
if (!Array.isArray(entries)) {
|
|
100
|
+
return []
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Sort by generated_at descending (most recent first)
|
|
104
|
+
const sorted = [...entries].sort((a, b) => {
|
|
105
|
+
const dateA = new Date(a.generated_at || 0)
|
|
106
|
+
const dateB = new Date(b.generated_at || 0)
|
|
107
|
+
return dateB - dateA
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// Keep only the most recent entries
|
|
111
|
+
return sorted.slice(0, maxEntries)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Add a CI result to history
|
|
116
|
+
*
|
|
117
|
+
* @param {Object} result - Full CI result object
|
|
118
|
+
* @param {Object} history - Existing history object
|
|
119
|
+
* @returns {Object} Updated history object
|
|
120
|
+
*/
|
|
121
|
+
export function addToHistory(result, history) {
|
|
122
|
+
const entry = extractHistoryEntry(result)
|
|
123
|
+
|
|
124
|
+
// Check for duplicate run_id
|
|
125
|
+
const existingIndex = history.entries.findIndex((e) => e.run_id === entry.run_id)
|
|
126
|
+
|
|
127
|
+
if (existingIndex >= 0) {
|
|
128
|
+
// Update existing entry
|
|
129
|
+
history.entries[existingIndex] = entry
|
|
130
|
+
} else {
|
|
131
|
+
// Add new entry at the beginning
|
|
132
|
+
history.entries.unshift(entry)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Update metadata
|
|
136
|
+
history.meta = {
|
|
137
|
+
...history.meta,
|
|
138
|
+
updated_at: new Date().toISOString(),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return history
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Save history to file
|
|
146
|
+
*
|
|
147
|
+
* @param {Object} history - History object
|
|
148
|
+
* @param {string} filePath - Path to save
|
|
149
|
+
* @param {number} maxEntries - Maximum entries to keep
|
|
150
|
+
*/
|
|
151
|
+
export function saveHistory(history, filePath, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
152
|
+
// Prune before saving
|
|
153
|
+
history.entries = pruneHistory(history.entries, maxEntries)
|
|
154
|
+
history.meta.max_entries = maxEntries
|
|
155
|
+
history.meta.entry_count = history.entries.length
|
|
156
|
+
|
|
157
|
+
// Ensure directory exists
|
|
158
|
+
const dir = dirname(filePath)
|
|
159
|
+
if (!existsSync(dir)) {
|
|
160
|
+
mkdirSync(dir, { recursive: true })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Write file
|
|
164
|
+
writeFileSync(filePath, JSON.stringify(history, null, 2) + '\n')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get entries for a specific branch
|
|
169
|
+
*
|
|
170
|
+
* @param {Array} entries - History entries
|
|
171
|
+
* @param {string} branch - Branch name to filter
|
|
172
|
+
* @returns {Array} Filtered entries
|
|
173
|
+
*/
|
|
174
|
+
export function filterByBranch(entries, branch) {
|
|
175
|
+
if (!branch) return entries
|
|
176
|
+
return entries.filter((e) => e.branch === branch)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Get entries within a date range
|
|
181
|
+
*
|
|
182
|
+
* @param {Array} entries - History entries
|
|
183
|
+
* @param {number} days - Number of days to include
|
|
184
|
+
* @returns {Array} Filtered entries
|
|
185
|
+
*/
|
|
186
|
+
export function filterByDays(entries, days) {
|
|
187
|
+
if (!days || days <= 0) return entries
|
|
188
|
+
|
|
189
|
+
const cutoff = new Date()
|
|
190
|
+
cutoff.setDate(cutoff.getDate() - days)
|
|
191
|
+
|
|
192
|
+
return entries.filter((e) => {
|
|
193
|
+
const date = new Date(e.generated_at)
|
|
194
|
+
return date >= cutoff
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get statistics from history
|
|
200
|
+
*
|
|
201
|
+
* @param {Array} entries - History entries
|
|
202
|
+
* @returns {Object} Statistics object
|
|
203
|
+
*/
|
|
204
|
+
export function getHistoryStats(entries) {
|
|
205
|
+
if (!entries || entries.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
total_runs: 0,
|
|
208
|
+
oldest_run: null,
|
|
209
|
+
newest_run: null,
|
|
210
|
+
unique_branches: 0,
|
|
211
|
+
branches: [],
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const sorted = [...entries].sort((a, b) => {
|
|
216
|
+
return new Date(a.generated_at) - new Date(b.generated_at)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const branches = [...new Set(entries.map((e) => e.branch).filter(Boolean))]
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
total_runs: entries.length,
|
|
223
|
+
oldest_run: sorted[0]?.generated_at || null,
|
|
224
|
+
newest_run: sorted[sorted.length - 1]?.generated_at || null,
|
|
225
|
+
unique_branches: branches.length,
|
|
226
|
+
branches,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Process a CI result and update history
|
|
232
|
+
*
|
|
233
|
+
* Convenience function that loads, updates, and saves history.
|
|
234
|
+
*
|
|
235
|
+
* @param {Object} result - Full CI result object
|
|
236
|
+
* @param {string} historyPath - Path to history file
|
|
237
|
+
* @param {number} maxEntries - Maximum entries to keep
|
|
238
|
+
* @returns {Object} Updated history object
|
|
239
|
+
*/
|
|
240
|
+
export function processHistory(result, historyPath, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
241
|
+
const history = loadHistory(historyPath)
|
|
242
|
+
addToHistory(result, history)
|
|
243
|
+
saveHistory(history, historyPath, maxEntries)
|
|
244
|
+
return history
|
|
245
|
+
}
|
|
@@ -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
|
+
}
|