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.
- package/CHANGELOG.md +42 -0
- package/README.md +98 -5
- package/action.yml +21 -1
- package/dist/action.js +78 -63
- package/dist/classifier.js +70 -0
- package/dist/flake-store.js +211 -0
- package/dist/index.js +485 -21
- package/dist/llm-analyzer.js +106 -0
- package/dist/multi.js +116 -0
- package/dist/parser.js +116 -0
- package/dist/providers/circleci.js +106 -0
- package/dist/providers/github.js +103 -0
- package/dist/providers/gitlab.js +96 -0
- package/dist/providers/index.js +44 -0
- package/dist/providers/types.js +2 -0
- package/dist/repo-context.js +97 -0
- package/dist/reporter.js +1 -0
- package/package.json +6 -2
|
@@ -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
|
+
}
|