@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.
@@ -27,6 +27,8 @@ NC='\033[0m'
27
27
  declare -a ERRORS=()
28
28
  declare -a STEP_RESULTS=()
29
29
  CI_START_TIME=""
30
+ CI_TRACE_ID=""
31
+ CI_SPAN_ID=""
30
32
 
31
33
  # ==============================================================================
32
34
  # Logging Functions
@@ -58,6 +60,128 @@ log_debug() {
58
60
  fi
59
61
  }
60
62
 
63
+ # ==============================================================================
64
+ # Trace Context Functions (OpenTelemetry W3C Trace Context)
65
+ # ==============================================================================
66
+
67
+ # Generate a random hex string of specified length
68
+ # Usage: generate_hex_string 32
69
+ generate_hex_string() {
70
+ local length="${1:-32}"
71
+ # Try multiple methods for generating random hex
72
+ if command -v openssl &>/dev/null; then
73
+ openssl rand -hex "$((length / 2))" 2>/dev/null
74
+ elif [ -r /dev/urandom ]; then
75
+ head -c "$((length / 2))" /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c "$length"
76
+ else
77
+ # Fallback: use date + process ID + random
78
+ local seed="$$$(date +%s%N 2>/dev/null || date +%s)"
79
+ echo "$seed" | md5sum 2>/dev/null | head -c "$length" || echo "$seed" | head -c "$length"
80
+ fi
81
+ }
82
+
83
+ # Generate a W3C trace ID (32 hex chars = 128 bits)
84
+ # Usage: generate_trace_id
85
+ generate_trace_id() {
86
+ generate_hex_string 32
87
+ }
88
+
89
+ # Generate a W3C span ID (16 hex chars = 64 bits)
90
+ # Usage: generate_span_id
91
+ generate_span_id() {
92
+ generate_hex_string 16
93
+ }
94
+
95
+ # Initialize trace context for the CI run
96
+ # Sets TRACEPARENT env var if not already set
97
+ # Format: 00-{trace_id}-{span_id}-{flags}
98
+ # Usage: setup_trace_context
99
+ setup_trace_context() {
100
+ # Check if trace context already exists from environment
101
+ if [ -n "${TRACEPARENT:-}" ]; then
102
+ log_debug "Using existing TRACEPARENT: $TRACEPARENT"
103
+ # Extract trace_id and span_id from existing TRACEPARENT
104
+ CI_TRACE_ID=$(echo "$TRACEPARENT" | cut -d'-' -f2)
105
+ CI_SPAN_ID=$(echo "$TRACEPARENT" | cut -d'-' -f3)
106
+ return 0
107
+ fi
108
+
109
+ # Check for direct trace ID from environment
110
+ if [ -n "${OTEL_TRACE_ID:-}" ]; then
111
+ log_debug "Using OTEL_TRACE_ID: $OTEL_TRACE_ID"
112
+ CI_TRACE_ID="$OTEL_TRACE_ID"
113
+ CI_SPAN_ID=$(generate_span_id)
114
+ TRACEPARENT="00-${CI_TRACE_ID}-${CI_SPAN_ID}-01"
115
+ export TRACEPARENT
116
+ return 0
117
+ fi
118
+
119
+ # Check for GitHub Actions run ID (use as fallback correlation)
120
+ if [ -n "${GITHUB_RUN_ID:-}" ]; then
121
+ log_debug "Using GitHub run ID for trace correlation"
122
+ # Create deterministic trace_id from GitHub run info
123
+ local gh_seed="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
124
+ CI_TRACE_ID=$(echo "$gh_seed" | md5sum | head -c 32)
125
+ CI_SPAN_ID=$(generate_span_id)
126
+ TRACEPARENT="00-${CI_TRACE_ID}-${CI_SPAN_ID}-01"
127
+ export TRACEPARENT
128
+ log_debug "Generated TRACEPARENT from GitHub: $TRACEPARENT"
129
+ return 0
130
+ fi
131
+
132
+ # Generate new trace context for local execution
133
+ log_debug "Generating new trace context for local CI run"
134
+ CI_TRACE_ID=$(generate_trace_id)
135
+ CI_SPAN_ID=$(generate_span_id)
136
+ TRACEPARENT="00-${CI_TRACE_ID}-${CI_SPAN_ID}-01"
137
+ export TRACEPARENT
138
+
139
+ log_info "Trace ID: ${CI_TRACE_ID:0:8}... (local)"
140
+ log_debug "Full TRACEPARENT: $TRACEPARENT"
141
+ return 0
142
+ }
143
+
144
+ # Get the current trace ID
145
+ # Usage: get_trace_id
146
+ get_trace_id() {
147
+ echo "${CI_TRACE_ID:-}"
148
+ }
149
+
150
+ # Get trace context info for metadata
151
+ # Usage: get_trace_context_json
152
+ get_trace_context_json() {
153
+ local trace_id="${CI_TRACE_ID:-}"
154
+ local span_id="${CI_SPAN_ID:-}"
155
+ local traceparent="${TRACEPARENT:-}"
156
+
157
+ if [ -z "$trace_id" ]; then
158
+ echo "null"
159
+ return
160
+ fi
161
+
162
+ # Determine source of trace context
163
+ local source="local"
164
+ if [ -n "${GITHUB_RUN_ID:-}" ]; then
165
+ source="github"
166
+ elif [ -n "${OTEL_TRACE_ID:-}" ]; then
167
+ source="otel_env"
168
+ elif [ -n "${TRACEPARENT:-}" ] && [ "${CI_TRACE_ID:-}" != "$(echo "$TRACEPARENT" | cut -d'-' -f2)" ]; then
169
+ source="external"
170
+ fi
171
+
172
+ jq -n \
173
+ --arg trace_id "$trace_id" \
174
+ --arg span_id "$span_id" \
175
+ --arg traceparent "$traceparent" \
176
+ --arg source "$source" \
177
+ '{
178
+ trace_id: $trace_id,
179
+ span_id: $span_id,
180
+ traceparent: $traceparent,
181
+ source: $source
182
+ }'
183
+ }
184
+
61
185
  # ==============================================================================
62
186
  # Version Compatibility Check
63
187
  # ==============================================================================
@@ -432,6 +556,10 @@ export_ci_data() {
432
556
  # Get scope
433
557
  local scope="${CI_SCOPE:-all}"
434
558
 
559
+ # Get trace context
560
+ local trace_context
561
+ trace_context=$(get_trace_context_json)
562
+
435
563
  # Generate intermediate data file for Node.js generator
436
564
  jq -n \
437
565
  --argjson steps "$steps_json" \
@@ -441,6 +569,7 @@ export_ci_data() {
441
569
  --arg finished_at "$now" \
442
570
  --arg project_name "$project_name" \
443
571
  --arg scope "$scope" \
572
+ --argjson trace_context "$trace_context" \
444
573
  '{
445
574
  steps: $steps,
446
575
  errors: $errors,
@@ -454,7 +583,8 @@ export_ci_data() {
454
583
  },
455
584
  metadata: {
456
585
  scope: $scope
457
- }
586
+ },
587
+ trace_context: $trace_context
458
588
  }' > "$output_file"
459
589
 
460
590
  log_debug "CI data exported to: $output_file"
@@ -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
@@ -58,6 +116,33 @@ fi
58
116
  log_info "Loading project configuration: $CONFIG_FILE"
59
117
  source "$CONFIG_FILE"
60
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
+
61
146
  # ==============================================================================
62
147
  # Validate Configuration
63
148
  # ==============================================================================
@@ -86,6 +171,9 @@ if [ "$CI_SCOPE" != "all" ]; then
86
171
  fi
87
172
  echo ""
88
173
 
174
+ # Setup trace context (generates trace_id if not provided externally)
175
+ setup_trace_context
176
+
89
177
  # Start timing
90
178
  ci_start
91
179
 
@@ -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
+ }