ci-triage 0.1.0 → 0.3.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,211 @@
1
+ import Database from 'better-sqlite3';
2
+ import { mkdirSync, statSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ let _db = null;
6
+ function dbPath() {
7
+ const dir = join(homedir(), '.ci-triage');
8
+ mkdirSync(dir, { recursive: true });
9
+ return join(dir, 'flake.db');
10
+ }
11
+ function getDb() {
12
+ if (_db)
13
+ return _db;
14
+ _db = new Database(dbPath());
15
+ bootstrap(_db);
16
+ return _db;
17
+ }
18
+ function bootstrap(db) {
19
+ db.exec(`
20
+ CREATE TABLE IF NOT EXISTS runs (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ repo TEXT NOT NULL,
23
+ run_id TEXT NOT NULL,
24
+ created_at TEXT NOT NULL
25
+ );
26
+
27
+ CREATE TABLE IF NOT EXISTS test_results (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ run_id INTEGER NOT NULL,
30
+ test_name TEXT NOT NULL,
31
+ status TEXT NOT NULL,
32
+ FOREIGN KEY (run_id) REFERENCES runs(id)
33
+ );
34
+
35
+ CREATE TABLE IF NOT EXISTS remediations (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ repo TEXT NOT NULL,
38
+ run_id TEXT NOT NULL,
39
+ fix_action TEXT NOT NULL,
40
+ description TEXT NOT NULL,
41
+ resolved_at TEXT NOT NULL,
42
+ resolved_commit TEXT,
43
+ verified_clean_run_id TEXT
44
+ );
45
+
46
+ CREATE INDEX IF NOT EXISTS idx_runs_repo ON runs(repo);
47
+ CREATE INDEX IF NOT EXISTS idx_test_results_run_id ON test_results(run_id);
48
+ CREATE INDEX IF NOT EXISTS idx_test_results_test_name ON test_results(test_name);
49
+ CREATE INDEX IF NOT EXISTS idx_remediations_repo ON remediations(repo);
50
+ `);
51
+ }
52
+ /** Persist a run and its test outcomes. */
53
+ export function persistRun(repo, runId, createdAt, tests) {
54
+ const db = getDb();
55
+ // Deduplicate: skip if run already stored
56
+ const existing = db.prepare('SELECT id FROM runs WHERE repo = ? AND run_id = ?').get(repo, String(runId));
57
+ if (existing)
58
+ return;
59
+ const insertRun = db.prepare('INSERT INTO runs (repo, run_id, created_at) VALUES (?, ?, ?)');
60
+ const insertTest = db.prepare('INSERT INTO test_results (run_id, test_name, status) VALUES (?, ?, ?)');
61
+ const tx = db.transaction(() => {
62
+ const result = insertRun.run(repo, String(runId), createdAt);
63
+ const rowId = result.lastInsertRowid;
64
+ for (const [testName, status] of Object.entries(tests)) {
65
+ insertTest.run(rowId, testName, status);
66
+ }
67
+ });
68
+ tx();
69
+ }
70
+ /** Get flake stats for all known tests in a repo. */
71
+ export function getFlakes(repo) {
72
+ const db = getDb();
73
+ const rows = db.prepare(`
74
+ SELECT
75
+ tr.test_name,
76
+ SUM(CASE WHEN tr.status IN ('fail', 'error') THEN 1 ELSE 0 END) AS fail_count,
77
+ SUM(CASE WHEN tr.status = 'pass' THEN 1 ELSE 0 END) AS pass_count,
78
+ COUNT(*) AS total_count,
79
+ MAX(r.created_at) AS last_seen
80
+ FROM test_results tr
81
+ JOIN runs r ON tr.run_id = r.id
82
+ WHERE r.repo = ?
83
+ GROUP BY tr.test_name
84
+ HAVING fail_count > 0 AND pass_count > 0
85
+ ORDER BY fail_count DESC
86
+ `).all(repo);
87
+ return rows.map((r) => ({
88
+ ...r,
89
+ repo,
90
+ flake_ratio: r.total_count > 0 ? r.fail_count / r.total_count : 0,
91
+ }));
92
+ }
93
+ /** Check if a specific test is flaky in a repo based on stored history. */
94
+ export function isFlakySqlite(repo, testName) {
95
+ const db = getDb();
96
+ const row = db.prepare(`
97
+ SELECT
98
+ SUM(CASE WHEN tr.status IN ('fail', 'error') THEN 1 ELSE 0 END) AS fail_count,
99
+ SUM(CASE WHEN tr.status = 'pass' THEN 1 ELSE 0 END) AS pass_count,
100
+ COUNT(*) AS total_count
101
+ FROM test_results tr
102
+ JOIN runs r ON tr.run_id = r.id
103
+ WHERE r.repo = ? AND tr.test_name = ?
104
+ `).get(repo, testName);
105
+ if (!row || row.total_count === 0) {
106
+ return { is_flaky: false, fail_count: 0, pass_count: 0, flake_ratio: 0 };
107
+ }
108
+ const flake_ratio = row.fail_count / row.total_count;
109
+ const is_flaky = row.fail_count > 0 && row.pass_count > 0;
110
+ return { is_flaky, fail_count: row.fail_count, pass_count: row.pass_count, flake_ratio };
111
+ }
112
+ /** Remove runs older than `daysOld` days. Returns number of runs deleted. */
113
+ export function pruneRuns(daysOld = 90) {
114
+ const db = getDb();
115
+ const cutoff = new Date(Date.now() - daysOld * 24 * 60 * 60 * 1000).toISOString();
116
+ // Delete test_results for old runs first (FK constraint)
117
+ db.prepare(`
118
+ DELETE FROM test_results
119
+ WHERE run_id IN (SELECT id FROM runs WHERE created_at < ?)
120
+ `).run(cutoff);
121
+ const result = db.prepare('DELETE FROM runs WHERE created_at < ?').run(cutoff);
122
+ return result.changes;
123
+ }
124
+ export function getDbStats(repo) {
125
+ const db = getDb();
126
+ const path = dbPath();
127
+ const runCountRow = repo
128
+ ? db.prepare('SELECT COUNT(*) AS count FROM runs WHERE repo = ?').get(repo)
129
+ : db.prepare('SELECT COUNT(*) AS count FROM runs').get();
130
+ const testResultCountRow = repo
131
+ ? db.prepare(`
132
+ SELECT COUNT(*) AS count
133
+ FROM test_results tr
134
+ JOIN runs r ON tr.run_id = r.id
135
+ WHERE r.repo = ?
136
+ `).get(repo)
137
+ : db.prepare('SELECT COUNT(*) AS count FROM test_results').get();
138
+ const flakyTestCountRow = repo
139
+ ? db.prepare(`
140
+ SELECT COUNT(*) AS count
141
+ FROM (
142
+ SELECT
143
+ tr.test_name
144
+ FROM test_results tr
145
+ JOIN runs r ON tr.run_id = r.id
146
+ WHERE r.repo = ?
147
+ GROUP BY tr.test_name
148
+ HAVING
149
+ SUM(CASE WHEN tr.status IN ('fail', 'error') THEN 1 ELSE 0 END) > 0
150
+ AND SUM(CASE WHEN tr.status = 'pass' THEN 1 ELSE 0 END) > 0
151
+ ) t
152
+ `).get(repo)
153
+ : db.prepare(`
154
+ SELECT COUNT(*) AS count
155
+ FROM (
156
+ SELECT
157
+ r.repo,
158
+ tr.test_name
159
+ FROM test_results tr
160
+ JOIN runs r ON tr.run_id = r.id
161
+ GROUP BY r.repo, tr.test_name
162
+ HAVING
163
+ SUM(CASE WHEN tr.status IN ('fail', 'error') THEN 1 ELSE 0 END) > 0
164
+ AND SUM(CASE WHEN tr.status = 'pass' THEN 1 ELSE 0 END) > 0
165
+ ) t
166
+ `).get();
167
+ return {
168
+ run_count: runCountRow?.count ?? 0,
169
+ test_result_count: testResultCountRow?.count ?? 0,
170
+ flaky_test_count: flakyTestCountRow?.count ?? 0,
171
+ db_path: path,
172
+ db_size_bytes: statSync(path).size,
173
+ };
174
+ }
175
+ /** Mark a run as resolved. Deduplicates on (repo, run_id). */
176
+ export function markResolved(repo, runId, fixAction, description, resolvedCommit) {
177
+ const db = getDb();
178
+ const existing = db.prepare('SELECT id FROM remediations WHERE repo = ? AND run_id = ?').get(repo, String(runId));
179
+ if (existing)
180
+ return;
181
+ db.prepare('INSERT INTO remediations (repo, run_id, fix_action, description, resolved_at, resolved_commit) VALUES (?, ?, ?, ?, ?, ?)').run(repo, String(runId), fixAction, description, new Date().toISOString(), resolvedCommit ?? null);
182
+ }
183
+ /** Mark a subsequent clean run as verifying the fix. */
184
+ export function markVerified(repo, runId, cleanRunId) {
185
+ const db = getDb();
186
+ db.prepare('UPDATE remediations SET verified_clean_run_id = ? WHERE repo = ? AND run_id = ?')
187
+ .run(String(cleanRunId), repo, String(runId));
188
+ }
189
+ /** Get remediation history for a repo, newest first. */
190
+ export function getRemediations(repo) {
191
+ const db = getDb();
192
+ const rows = db.prepare(`
193
+ SELECT id, repo, run_id, fix_action, description, resolved_at, resolved_commit, verified_clean_run_id
194
+ FROM remediations
195
+ WHERE repo = ?
196
+ ORDER BY id DESC
197
+ `).all(repo);
198
+ return rows.map((r) => ({
199
+ ...r,
200
+ resolved_commit: r.resolved_commit ?? undefined,
201
+ verified_clean_run_id: r.verified_clean_run_id ?? undefined,
202
+ is_verified: !!r.verified_clean_run_id,
203
+ }));
204
+ }
205
+ /** Close the DB (useful in tests). */
206
+ export function closeDb() {
207
+ if (_db) {
208
+ _db.close();
209
+ _db = null;
210
+ }
211
+ }