@brela-dev/cli 0.1.0-alpha.1
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 +90 -0
- package/dist/commands/daemon-cmd.d.ts +3 -0
- package/dist/commands/daemon-cmd.d.ts.map +1 -0
- package/dist/commands/daemon-cmd.js +94 -0
- package/dist/commands/daemon-cmd.js.map +1 -0
- package/dist/commands/explain.d.ts +3 -0
- package/dist/commands/explain.d.ts.map +1 -0
- package/dist/commands/explain.js +363 -0
- package/dist/commands/explain.js.map +1 -0
- package/dist/commands/hook.d.ts +5 -0
- package/dist/commands/hook.d.ts.map +1 -0
- package/dist/commands/hook.js +201 -0
- package/dist/commands/hook.js.map +1 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +298 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/report.d.ts +43 -0
- package/dist/commands/report.d.ts.map +1 -0
- package/dist/commands/report.js +725 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +29 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { simpleGit } from 'simple-git';
|
|
6
|
+
import { DetectionMethod, AITool } from '@brela-dev/core';
|
|
7
|
+
import { BrelaExit, logError } from '../errors.js';
|
|
8
|
+
const _require = createRequire(import.meta.url);
|
|
9
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
10
|
+
function toDateStr(d) { return d.toISOString().slice(0, 10); }
|
|
11
|
+
const TOOL_LABELS = {
|
|
12
|
+
COPILOT: 'Copilot', COPILOT_AGENT: 'Copilot Agent', COPILOT_CLI: 'Copilot CLI',
|
|
13
|
+
CLAUDE_CODE: 'Claude Code', CLAUDE_CODE_AGENT: 'Claude Code Agent',
|
|
14
|
+
CURSOR: 'Cursor', CURSOR_AGENT: 'Cursor Agent',
|
|
15
|
+
CODEIUM: 'Codeium', CLINE: 'Cline', AIDER: 'Aider', CONTINUE: 'Continue',
|
|
16
|
+
CHATGPT_PASTE: 'ChatGPT Paste', GENERIC_AGENT: 'AI Agent', UNKNOWN: 'Unknown',
|
|
17
|
+
};
|
|
18
|
+
function toolLabel(t) { return TOOL_LABELS[t] ?? t; }
|
|
19
|
+
const TRAILER_MAP = {
|
|
20
|
+
claude: AITool.CLAUDE_CODE, copilot: AITool.COPILOT,
|
|
21
|
+
cursor: AITool.CURSOR, codeium: AITool.CODEIUM,
|
|
22
|
+
};
|
|
23
|
+
function toolFromCoAuthor(name) {
|
|
24
|
+
const lc = name.toLowerCase();
|
|
25
|
+
for (const [k, v] of Object.entries(TRAILER_MAP)) {
|
|
26
|
+
if (lc.includes(k))
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// ── Data readers ──────────────────────────────────────────────────────────────
|
|
32
|
+
function readCommitsJsonl(brelaDir) {
|
|
33
|
+
const f = path.join(brelaDir, 'commits.jsonl');
|
|
34
|
+
if (!fs.existsSync(f))
|
|
35
|
+
return [];
|
|
36
|
+
return fs.readFileSync(f, 'utf8').split('\n').filter(l => l.trim())
|
|
37
|
+
.flatMap(l => { try {
|
|
38
|
+
return [JSON.parse(l)];
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
} });
|
|
43
|
+
}
|
|
44
|
+
function readSessionEntries(brelaDir, fromDate) {
|
|
45
|
+
const dir = path.join(brelaDir, 'sessions');
|
|
46
|
+
if (!fs.existsSync(dir))
|
|
47
|
+
return [];
|
|
48
|
+
const cutoffMs = fromDate.getTime();
|
|
49
|
+
const results = [];
|
|
50
|
+
for (const file of fs.readdirSync(dir)) {
|
|
51
|
+
if (!file.endsWith('.json'))
|
|
52
|
+
continue;
|
|
53
|
+
const fileMs = new Date(file.replace('.json', '')).getTime();
|
|
54
|
+
if (isNaN(fileMs) || fileMs < cutoffMs)
|
|
55
|
+
continue;
|
|
56
|
+
for (const line of fs.readFileSync(path.join(dir, file), 'utf8').split('\n')) {
|
|
57
|
+
if (!line.trim())
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
results.push(JSON.parse(line));
|
|
61
|
+
}
|
|
62
|
+
catch { /* skip */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return results;
|
|
66
|
+
}
|
|
67
|
+
async function readGitData(projectRoot, fromDate) {
|
|
68
|
+
const empty = {
|
|
69
|
+
commits: [], trailerEntries: [],
|
|
70
|
+
perDayAdded: new Map(), perFileTotal: new Map(),
|
|
71
|
+
};
|
|
72
|
+
if (!fs.existsSync(path.join(projectRoot, '.git')))
|
|
73
|
+
return empty;
|
|
74
|
+
try {
|
|
75
|
+
const git = simpleGit(projectRoot);
|
|
76
|
+
const since = toDateStr(fromDate);
|
|
77
|
+
// Single pass: COMMIT header lines interleaved with numstat lines
|
|
78
|
+
const rawStat = await git.raw([
|
|
79
|
+
'log', `--since=${since}`,
|
|
80
|
+
'--format=PCOMMIT|%H|%h|%ae|%an|%ai|%s',
|
|
81
|
+
'--numstat',
|
|
82
|
+
]);
|
|
83
|
+
// Separate pass: full commit bodies for trailer + reviewer detection
|
|
84
|
+
const rawBodies = await git.raw([
|
|
85
|
+
'log', `--since=${since}`,
|
|
86
|
+
'--format=PSTART|%H%n%B%nPEND',
|
|
87
|
+
]);
|
|
88
|
+
// ── Parse numstat log ────────────────────────────────────────────────────
|
|
89
|
+
const commits = [];
|
|
90
|
+
const perDayAdded = new Map();
|
|
91
|
+
const perFileTotal = new Map();
|
|
92
|
+
let cur = null;
|
|
93
|
+
for (const line of rawStat.split('\n')) {
|
|
94
|
+
if (line.startsWith('PCOMMIT|')) {
|
|
95
|
+
if (cur)
|
|
96
|
+
commits.push(cur);
|
|
97
|
+
const parts = line.split('|');
|
|
98
|
+
const date = (parts[5] ?? '').slice(0, 10);
|
|
99
|
+
cur = {
|
|
100
|
+
hash: parts[1] ?? '', shortHash: parts[2] ?? '',
|
|
101
|
+
authorEmail: parts[3] ?? '', authorName: parts[4] ?? '',
|
|
102
|
+
date, subject: parts.slice(6).join('|'),
|
|
103
|
+
hasReviewer: false, linesAdded: 0,
|
|
104
|
+
fileLines: new Map(), files: [],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
else if (cur) {
|
|
108
|
+
const cols = line.split('\t');
|
|
109
|
+
if (cols.length === 3 && cols[0] !== undefined && cols[0] !== '-') {
|
|
110
|
+
const added = parseInt(cols[0], 10);
|
|
111
|
+
const file = cols[2] ?? '';
|
|
112
|
+
if (!isNaN(added) && added > 0 && file) {
|
|
113
|
+
cur.linesAdded += added;
|
|
114
|
+
cur.files.push(file);
|
|
115
|
+
cur.fileLines.set(file, (cur.fileLines.get(file) ?? 0) + added);
|
|
116
|
+
perDayAdded.set(cur.date, (perDayAdded.get(cur.date) ?? 0) + added);
|
|
117
|
+
perFileTotal.set(file, (perFileTotal.get(file) ?? 0) + added);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (cur)
|
|
123
|
+
commits.push(cur);
|
|
124
|
+
// ── Parse bodies: reviewer detection + co-author trailers ───────────────
|
|
125
|
+
const bodyMap = new Map();
|
|
126
|
+
let bHash = '';
|
|
127
|
+
const bLines = [];
|
|
128
|
+
for (const line of rawBodies.split('\n')) {
|
|
129
|
+
if (line.startsWith('PSTART|')) {
|
|
130
|
+
if (bHash)
|
|
131
|
+
bodyMap.set(bHash, bLines.join('\n'));
|
|
132
|
+
bHash = line.slice('PSTART|'.length);
|
|
133
|
+
bLines.length = 0;
|
|
134
|
+
}
|
|
135
|
+
else if (line === 'PEND') {
|
|
136
|
+
if (bHash)
|
|
137
|
+
bodyMap.set(bHash, bLines.join('\n'));
|
|
138
|
+
bHash = '';
|
|
139
|
+
bLines.length = 0;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
bLines.push(line);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (bHash)
|
|
146
|
+
bodyMap.set(bHash, bLines.join('\n'));
|
|
147
|
+
const trailerEntries = [];
|
|
148
|
+
for (const commit of commits) {
|
|
149
|
+
const body = bodyMap.get(commit.hash) ?? '';
|
|
150
|
+
const lc = body.toLowerCase();
|
|
151
|
+
commit.hasReviewer = lc.includes('reviewed-by:') || lc.includes('approved-by:');
|
|
152
|
+
const coRe = /co-authored-by:\s*([^<\n]+)/gi;
|
|
153
|
+
let m;
|
|
154
|
+
while ((m = coRe.exec(body)) !== null) {
|
|
155
|
+
const tool = toolFromCoAuthor(m[1] ?? '');
|
|
156
|
+
if (tool !== null) {
|
|
157
|
+
trailerEntries.push({
|
|
158
|
+
commitHash: commit.hash, date: commit.date,
|
|
159
|
+
tool, files: commit.files, linesAdded: commit.linesAdded,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { commits, trailerEntries, perDayAdded, perFileTotal };
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return empty;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// ── Metrics ───────────────────────────────────────────────────────────────────
|
|
171
|
+
export async function computeMetrics(projectRoot, days) {
|
|
172
|
+
const now = new Date();
|
|
173
|
+
const fromDate = new Date(now);
|
|
174
|
+
fromDate.setDate(fromDate.getDate() - days);
|
|
175
|
+
fromDate.setHours(0, 0, 0, 0);
|
|
176
|
+
const brelaDir = path.join(projectRoot, '.brela');
|
|
177
|
+
const sessionEntries = readSessionEntries(brelaDir, fromDate);
|
|
178
|
+
// Oldest entry determines data freshness
|
|
179
|
+
const oldest = sessionEntries.reduce((min, e) => {
|
|
180
|
+
const d = new Date(e.timestamp);
|
|
181
|
+
return !min || d < min ? d : min;
|
|
182
|
+
}, null);
|
|
183
|
+
const ageMs = oldest ? now.getTime() - oldest.getTime() : 0;
|
|
184
|
+
const insufficientData = sessionEntries.length === 0 || ageMs < 3 * 86_400_000;
|
|
185
|
+
const brelaCommits = readCommitsJsonl(brelaDir);
|
|
186
|
+
const gitData = await readGitData(projectRoot, fromDate);
|
|
187
|
+
// ── Backfill from git trailers ─────────────────────────────────────────────
|
|
188
|
+
const sessionKeys = new Set(sessionEntries.map(e => `${toDateStr(new Date(e.timestamp))}|${e.file}`));
|
|
189
|
+
const backfillEntries = gitData.trailerEntries.flatMap(te => te.files.map(file => ({
|
|
190
|
+
file,
|
|
191
|
+
tool: te.tool,
|
|
192
|
+
confidence: 'high',
|
|
193
|
+
detectionMethod: DetectionMethod.CO_AUTHOR_TRAILER,
|
|
194
|
+
linesStart: 0,
|
|
195
|
+
linesEnd: Math.floor(te.linesAdded / Math.max(te.files.length, 1)),
|
|
196
|
+
charsInserted: 0,
|
|
197
|
+
timestamp: `${te.date}T00:00:00Z`,
|
|
198
|
+
sessionId: 'git-trailer',
|
|
199
|
+
accepted: true,
|
|
200
|
+
}))).filter(e => !sessionKeys.has(`${toDateStr(new Date(e.timestamp))}|${e.file}`));
|
|
201
|
+
const allEntries = [...sessionEntries, ...backfillEntries];
|
|
202
|
+
// ── Aggregate per-file and per-tool ───────────────────────────────────────
|
|
203
|
+
const fileMap = new Map();
|
|
204
|
+
const toolTotals = new Map();
|
|
205
|
+
const confDist = { high: 0, medium: 0, low: 0 };
|
|
206
|
+
const aiByDay = new Map();
|
|
207
|
+
for (const e of allEntries) {
|
|
208
|
+
const lines = Math.max(0, e.linesEnd - e.linesStart);
|
|
209
|
+
const label = toolLabel(e.tool);
|
|
210
|
+
const date = toDateStr(new Date(e.timestamp));
|
|
211
|
+
// File accumulation
|
|
212
|
+
if (!fileMap.has(e.file))
|
|
213
|
+
fileMap.set(e.file, { aiLines: 0, tools: new Map() });
|
|
214
|
+
const fstat = fileMap.get(e.file);
|
|
215
|
+
fstat.aiLines += lines;
|
|
216
|
+
fstat.tools.set(label, (fstat.tools.get(label) ?? 0) + lines);
|
|
217
|
+
// Global tool totals
|
|
218
|
+
toolTotals.set(label, (toolTotals.get(label) ?? 0) + lines);
|
|
219
|
+
// Confidence
|
|
220
|
+
if (e.confidence === 'high')
|
|
221
|
+
confDist.high++;
|
|
222
|
+
else if (e.confidence === 'medium')
|
|
223
|
+
confDist.medium++;
|
|
224
|
+
else
|
|
225
|
+
confDist.low++;
|
|
226
|
+
// Day buckets
|
|
227
|
+
aiByDay.set(date, (aiByDay.get(date) ?? 0) + lines);
|
|
228
|
+
}
|
|
229
|
+
const totalAiLines = [...toolTotals.values()].reduce((a, b) => a + b, 0);
|
|
230
|
+
const totalGitLines = [...gitData.perDayAdded.values()].reduce((a, b) => a + b, 0);
|
|
231
|
+
const totalLines = Math.max(totalGitLines, totalAiLines);
|
|
232
|
+
const totalHumanLines = Math.max(0, totalLines - totalAiLines);
|
|
233
|
+
const aiPercentage = totalLines > 0 ? (totalAiLines / totalLines) * 100 : 0;
|
|
234
|
+
// ── Per-tool breakdown (% of AI lines) ────────────────────────────────────
|
|
235
|
+
const perToolBreakdown = {};
|
|
236
|
+
for (const [t, n] of toolTotals) {
|
|
237
|
+
perToolBreakdown[t] = totalAiLines > 0 ? (n / totalAiLines) * 100 : 0;
|
|
238
|
+
}
|
|
239
|
+
// ── Per-file heatmap (top 20 by AI%) ──────────────────────────────────────
|
|
240
|
+
const perFileHeatmap = [...fileMap.entries()].map(([file, stat]) => {
|
|
241
|
+
const totalLines = Math.max(gitData.perFileTotal.get(file) ?? 0, stat.aiLines);
|
|
242
|
+
const topTool = [...stat.tools.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'Unknown';
|
|
243
|
+
return {
|
|
244
|
+
file, aiLines: stat.aiLines, totalLines,
|
|
245
|
+
aiPct: totalLines > 0 ? (stat.aiLines / totalLines) * 100 : 100,
|
|
246
|
+
topTool,
|
|
247
|
+
};
|
|
248
|
+
}).sort((a, b) => b.aiPct - a.aiPct).slice(0, 20);
|
|
249
|
+
// ── Per-day trend ──────────────────────────────────────────────────────────
|
|
250
|
+
const perDayTrend = Array.from({ length: days }, (_, i) => {
|
|
251
|
+
const d = new Date(now);
|
|
252
|
+
d.setDate(d.getDate() - (days - 1 - i));
|
|
253
|
+
const date = toDateStr(d);
|
|
254
|
+
const aiLines = aiByDay.get(date) ?? 0;
|
|
255
|
+
const humanLines = Math.max(0, (gitData.perDayAdded.get(date) ?? 0) - aiLines);
|
|
256
|
+
return { date, humanLines, aiLines };
|
|
257
|
+
});
|
|
258
|
+
// ── Unreviewed AI commits ──────────────────────────────────────────────────
|
|
259
|
+
const unreviewedAiCommits = [];
|
|
260
|
+
for (const commit of gitData.commits) {
|
|
261
|
+
if (commit.hasReviewer)
|
|
262
|
+
continue;
|
|
263
|
+
const record = brelaCommits.find(r => r.commitHash === commit.hash);
|
|
264
|
+
if (!record)
|
|
265
|
+
continue;
|
|
266
|
+
const aiFileSet = new Set(record.files.map(f => f.path));
|
|
267
|
+
let aiLines = 0, total = 0;
|
|
268
|
+
for (const [file, n] of commit.fileLines) {
|
|
269
|
+
total += n;
|
|
270
|
+
if (aiFileSet.has(file))
|
|
271
|
+
aiLines += n;
|
|
272
|
+
}
|
|
273
|
+
const aiPct = total > 0 ? (aiLines / total) * 100 : 0;
|
|
274
|
+
if (aiPct < 60)
|
|
275
|
+
continue;
|
|
276
|
+
unreviewedAiCommits.push({
|
|
277
|
+
hash: commit.hash, shortHash: commit.shortHash,
|
|
278
|
+
date: commit.date, message: commit.subject,
|
|
279
|
+
author: commit.authorName || commit.authorEmail,
|
|
280
|
+
aiPct,
|
|
281
|
+
tools: [...new Set(record.files.map(f => toolLabel(f.tool)))],
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
generatedAt: now.toISOString(),
|
|
286
|
+
projectRoot, daysAnalysed: days,
|
|
287
|
+
dateFrom: toDateStr(fromDate), dateTo: toDateStr(now),
|
|
288
|
+
insufficientData, aiPercentage, totalAiLines, totalHumanLines,
|
|
289
|
+
perToolBreakdown, perFileHeatmap, perDayTrend,
|
|
290
|
+
confidenceDistribution: confDist,
|
|
291
|
+
unreviewedAiCommits,
|
|
292
|
+
backfillCount: backfillEntries.length,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
// ── Chart.js loader ───────────────────────────────────────────────────────────
|
|
296
|
+
function loadChartJs() {
|
|
297
|
+
const main = _require.resolve('chart.js');
|
|
298
|
+
const umd = path.join(path.dirname(main), 'chart.umd.js');
|
|
299
|
+
if (fs.existsSync(umd))
|
|
300
|
+
return fs.readFileSync(umd, 'utf8');
|
|
301
|
+
throw new Error(`chart.umd.js not found near ${main} — run npm install`);
|
|
302
|
+
}
|
|
303
|
+
// ── HTML generation ───────────────────────────────────────────────────────────
|
|
304
|
+
function pct(n) { return n.toFixed(1) + '%'; }
|
|
305
|
+
function pctPill(p) {
|
|
306
|
+
let bg, fg;
|
|
307
|
+
if (p <= 30) {
|
|
308
|
+
bg = '#DCFCE7';
|
|
309
|
+
fg = '#15803D';
|
|
310
|
+
}
|
|
311
|
+
else if (p <= 60) {
|
|
312
|
+
bg = '#FEF9C3';
|
|
313
|
+
fg = '#A16207';
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
bg = '#FEE2E2';
|
|
317
|
+
fg = '#B91C1C';
|
|
318
|
+
}
|
|
319
|
+
return `<span style="display:inline-block;padding:2px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${bg};color:${fg}">${p.toFixed(1)}%</span>`;
|
|
320
|
+
}
|
|
321
|
+
function generateHtml(m, chartJs) {
|
|
322
|
+
const metricsJson = JSON.stringify(m, null, 0);
|
|
323
|
+
const repo = escHtml(path.basename(m.projectRoot));
|
|
324
|
+
const dateRange = `${m.dateFrom} – ${m.dateTo}`;
|
|
325
|
+
const generatedAt = new Date(m.generatedAt).toLocaleString();
|
|
326
|
+
const topTool = Object.entries(m.perToolBreakdown)
|
|
327
|
+
.sort((a, b) => b[1] - a[1])[0]?.[0] ?? '—';
|
|
328
|
+
// ── Warning banner ────────────────────────────────────────────────────────
|
|
329
|
+
const warningBanner = m.insufficientData ? `
|
|
330
|
+
<div id="warningBanner" style="display:flex;align-items:center;justify-content:space-between;
|
|
331
|
+
background:#FFFBEB;border:1px solid #F59E0B;border-radius:8px;
|
|
332
|
+
padding:12px 16px;margin-bottom:24px;font-size:13px;color:#92400E">
|
|
333
|
+
<span>⚠️ Insufficient data — report covers less than 3 days. Results may not be representative.</span>
|
|
334
|
+
<button id="dismissWarning" style="background:none;border:none;cursor:pointer;
|
|
335
|
+
font-size:16px;color:#92400E;padding:0 4px;line-height:1">✕</button>
|
|
336
|
+
</div>` : '';
|
|
337
|
+
// ── File heatmap rows ─────────────────────────────────────────────────────
|
|
338
|
+
const heatmapRows = m.perFileHeatmap.map((f, i) => {
|
|
339
|
+
const rowBg = i % 2 === 1 ? '#F9FAFB' : '#ffffff';
|
|
340
|
+
return `<tr data-pct="${f.aiPct.toFixed(2)}" data-file="${escHtml(f.file)}" data-tool="${escHtml(f.topTool)}" data-ai="${f.aiLines}" data-total="${f.totalLines}"
|
|
341
|
+
style="background:${rowBg};height:44px;border-bottom:1px solid #F3F4F6"
|
|
342
|
+
onmouseover="this.style.background='#F0F9FF'" onmouseout="this.style.background='${rowBg}'">
|
|
343
|
+
<td style="padding:0 16px;font-family:'SF Mono',Menlo,monospace;font-size:12px;color:#111827;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(f.file)}</td>
|
|
344
|
+
<td style="padding:0 16px;font-size:13px;color:#6B7280">${escHtml(f.topTool)}</td>
|
|
345
|
+
<td style="padding:0 16px;font-size:13px;color:#111827;text-align:right">${f.aiLines.toLocaleString()}</td>
|
|
346
|
+
<td style="padding:0 16px;font-size:13px;color:#111827;text-align:right">${f.totalLines.toLocaleString()}</td>
|
|
347
|
+
<td style="padding:0 16px">${pctPill(f.aiPct)}</td>
|
|
348
|
+
</tr>`;
|
|
349
|
+
}).join('\n');
|
|
350
|
+
// ── Flagged commit cards ──────────────────────────────────────────────────
|
|
351
|
+
const flaggedCards = m.unreviewedAiCommits.map(c => `
|
|
352
|
+
<div style="background:#fff;border:1px solid #E5E7EB;border-left:4px solid #EF4444;
|
|
353
|
+
border-radius:8px;padding:16px 20px;margin-bottom:8px">
|
|
354
|
+
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px">
|
|
355
|
+
<div style="min-width:0;overflow:hidden">
|
|
356
|
+
<span style="font-family:'SF Mono',Menlo,monospace;font-size:12px;color:#6B7280">${escHtml(c.shortHash)}</span>
|
|
357
|
+
<span style="font-size:13px;font-weight:500;color:#111827;margin-left:10px">${escHtml(c.message)}</span>
|
|
358
|
+
</div>
|
|
359
|
+
<span style="flex-shrink:0;background:#FEE2E2;color:#B91C1C;padding:2px 10px;
|
|
360
|
+
border-radius:12px;font-size:11px;font-weight:600;white-space:nowrap">AI: ${pct(c.aiPct)}</span>
|
|
361
|
+
</div>
|
|
362
|
+
<div style="margin-top:6px;font-size:12px;color:#6B7280">
|
|
363
|
+
${escHtml(c.author)} · ${escHtml(c.date)} · ${c.tools.map(escHtml).join(', ')}
|
|
364
|
+
</div>
|
|
365
|
+
</div>`).join('\n');
|
|
366
|
+
const backfillNote = m.backfillCount > 0
|
|
367
|
+
? `<p style="font-size:12px;color:#6B7280;margin-top:12px">${m.backfillCount} entries backfilled from git co-author trailers (confidence: high).</p>`
|
|
368
|
+
: '';
|
|
369
|
+
return `<!DOCTYPE html>
|
|
370
|
+
<html lang="en">
|
|
371
|
+
<head>
|
|
372
|
+
<meta charset="utf-8">
|
|
373
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
374
|
+
<title>Brela Report — ${repo} — ${m.dateTo}</title>
|
|
375
|
+
<style>
|
|
376
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
377
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
|
378
|
+
background:#F9FAFB;color:#111827;line-height:1.5;font-size:14px}
|
|
379
|
+
th.sorted-asc::after{content:' ↑'}
|
|
380
|
+
th.sorted-desc::after{content:' ↓'}
|
|
381
|
+
</style>
|
|
382
|
+
</head>
|
|
383
|
+
<body>
|
|
384
|
+
|
|
385
|
+
<!-- ── Nav ── -->
|
|
386
|
+
<nav style="background:#fff;border-bottom:1px solid #E5E7EB;padding:0 32px;height:56px;
|
|
387
|
+
display:flex;align-items:center;justify-content:space-between;
|
|
388
|
+
position:sticky;top:0;z-index:10">
|
|
389
|
+
<div style="display:flex;align-items:center;gap:10px">
|
|
390
|
+
<span style="font-size:18px">👻</span>
|
|
391
|
+
<span style="font-weight:600;font-size:15px;color:#111827">Brela Report</span>
|
|
392
|
+
</div>
|
|
393
|
+
<div style="font-size:13px;color:#6B7280">${repo} · ${dateRange} · Generated ${generatedAt}</div>
|
|
394
|
+
</nav>
|
|
395
|
+
|
|
396
|
+
<main style="max-width:1200px;margin:0 auto;padding:32px 24px">
|
|
397
|
+
|
|
398
|
+
${warningBanner}
|
|
399
|
+
|
|
400
|
+
<!-- ── Stats grid ── -->
|
|
401
|
+
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:28px">
|
|
402
|
+
|
|
403
|
+
<div style="background:#fff;border:1px solid #E5E7EB;border-radius:8px;padding:20px 24px">
|
|
404
|
+
<div style="font-size:11px;font-weight:600;color:#6B7280;letter-spacing:.06em;text-transform:uppercase;margin-bottom:8px">OVERALL AI%</div>
|
|
405
|
+
<div style="font-size:32px;font-weight:700;color:#1F8EFA">${pct(m.aiPercentage)}</div>
|
|
406
|
+
<div style="font-size:12px;color:#6B7280;margin-top:4px">${m.daysAnalysed} days analysed</div>
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
<div style="background:#fff;border:1px solid #E5E7EB;border-radius:8px;padding:20px 24px">
|
|
410
|
+
<div style="font-size:11px;font-weight:600;color:#6B7280;letter-spacing:.06em;text-transform:uppercase;margin-bottom:8px">AI LINES</div>
|
|
411
|
+
<div style="font-size:32px;font-weight:700;color:#111827">${m.totalAiLines.toLocaleString()}</div>
|
|
412
|
+
<div style="font-size:12px;color:#6B7280;margin-top:4px">lines attributed to AI</div>
|
|
413
|
+
</div>
|
|
414
|
+
|
|
415
|
+
<div style="background:#fff;border:1px solid #E5E7EB;border-radius:8px;padding:20px 24px">
|
|
416
|
+
<div style="font-size:11px;font-weight:600;color:#6B7280;letter-spacing:.06em;text-transform:uppercase;margin-bottom:8px">HUMAN LINES</div>
|
|
417
|
+
<div style="font-size:32px;font-weight:700;color:#111827">${m.totalHumanLines.toLocaleString()}</div>
|
|
418
|
+
<div style="font-size:12px;color:#6B7280;margin-top:4px">lines written by humans</div>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div style="background:#fff;border:1px solid #E5E7EB;border-radius:8px;padding:20px 24px">
|
|
422
|
+
<div style="font-size:11px;font-weight:600;color:#6B7280;letter-spacing:.06em;text-transform:uppercase;margin-bottom:8px">TOP TOOL</div>
|
|
423
|
+
<div style="font-size:22px;font-weight:700;color:#111827;margin-top:6px">${escHtml(topTool)}</div>
|
|
424
|
+
<div style="font-size:12px;color:#6B7280;margin-top:4px">most active AI tool</div>
|
|
425
|
+
</div>
|
|
426
|
+
|
|
427
|
+
<div style="background:#fff;border:1px solid ${m.unreviewedAiCommits.length > 0 ? '#FCA5A5' : '#E5E7EB'};border-radius:8px;padding:20px 24px">
|
|
428
|
+
<div style="font-size:11px;font-weight:600;color:#6B7280;letter-spacing:.06em;text-transform:uppercase;margin-bottom:8px">FLAGGED COMMITS</div>
|
|
429
|
+
<div style="font-size:32px;font-weight:700;color:${m.unreviewedAiCommits.length > 0 ? '#EF4444' : '#111827'}">${m.unreviewedAiCommits.length}</div>
|
|
430
|
+
<div style="font-size:12px;color:#6B7280;margin-top:4px">AI >60%, unreviewed</div>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<!-- ── Charts row ── -->
|
|
436
|
+
<div style="display:flex;gap:16px;margin-bottom:28px;align-items:flex-start">
|
|
437
|
+
|
|
438
|
+
<div style="flex:0 0 60%;background:#fff;border:1px solid #E5E7EB;border-radius:8px;padding:20px 24px">
|
|
439
|
+
<div style="font-size:13px;font-weight:600;color:#111827;margin-bottom:16px">Daily Trend</div>
|
|
440
|
+
<canvas id="trendChart"></canvas>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
<div style="flex:0 0 calc(40% - 16px);background:#fff;border:1px solid #E5E7EB;border-radius:8px;padding:20px 24px">
|
|
444
|
+
<div style="font-size:13px;font-weight:600;color:#111827;margin-bottom:16px">Tool Breakdown</div>
|
|
445
|
+
<canvas id="toolChart"></canvas>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<!-- ── File heatmap ── -->
|
|
451
|
+
<div style="background:#fff;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:28px;overflow:hidden">
|
|
452
|
+
<div style="padding:16px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;justify-content:space-between">
|
|
453
|
+
<span style="font-size:13px;font-weight:600;color:#111827">File Heatmap</span>
|
|
454
|
+
<span style="font-size:12px;color:#6B7280">top ${m.perFileHeatmap.length} files by AI%</span>
|
|
455
|
+
</div>
|
|
456
|
+
<div style="overflow-x:auto">
|
|
457
|
+
<table id="heatmapTable" style="width:100%;border-collapse:collapse">
|
|
458
|
+
<thead>
|
|
459
|
+
<tr style="background:#F9FAFB">
|
|
460
|
+
<th data-col="file" style="padding:10px 16px;text-align:left;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:.06em;cursor:pointer;white-space:nowrap;border-bottom:1px solid #E5E7EB">File</th>
|
|
461
|
+
<th data-col="tool" style="padding:10px 16px;text-align:left;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:.06em;cursor:pointer;white-space:nowrap;border-bottom:1px solid #E5E7EB">Top Tool</th>
|
|
462
|
+
<th data-col="ai" style="padding:10px 16px;text-align:right;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:.06em;cursor:pointer;white-space:nowrap;border-bottom:1px solid #E5E7EB">AI Lines</th>
|
|
463
|
+
<th data-col="total" style="padding:10px 16px;text-align:right;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:.06em;cursor:pointer;white-space:nowrap;border-bottom:1px solid #E5E7EB">Total Lines</th>
|
|
464
|
+
<th data-col="pct" style="padding:10px 16px;text-align:left;font-size:11px;font-weight:600;color:#6B7280;text-transform:uppercase;letter-spacing:.06em;cursor:pointer;white-space:nowrap;border-bottom:1px solid #E5E7EB">AI%</th>
|
|
465
|
+
</tr>
|
|
466
|
+
</thead>
|
|
467
|
+
<tbody>${heatmapRows}</tbody>
|
|
468
|
+
</table>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
<!-- ── Flagged commits ── -->
|
|
473
|
+
<div style="margin-bottom:28px">
|
|
474
|
+
<div style="font-size:13px;font-weight:600;color:#111827;margin-bottom:12px">
|
|
475
|
+
Flagged Commits
|
|
476
|
+
<span style="font-size:12px;font-weight:400;color:#6B7280;margin-left:6px">AI > 60%, unreviewed</span>
|
|
477
|
+
</div>
|
|
478
|
+
${m.unreviewedAiCommits.length === 0
|
|
479
|
+
? '<p style="font-size:13px;color:#6B7280">No flagged commits in this period.</p>'
|
|
480
|
+
: flaggedCards}
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
${backfillNote}
|
|
484
|
+
|
|
485
|
+
<!-- ── Export button ── -->
|
|
486
|
+
<div style="display:flex;justify-content:flex-end;margin-top:8px">
|
|
487
|
+
<button id="exportBtn" style="display:inline-flex;align-items:center;gap:6px;
|
|
488
|
+
padding:8px 16px;border-radius:6px;border:1px solid #E5E7EB;
|
|
489
|
+
background:#fff;color:#374151;font-size:13px;font-weight:500;
|
|
490
|
+
cursor:pointer">↓ Export JSON</button>
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
<footer style="margin-top:40px;padding-top:16px;border-top:1px solid #E5E7EB;
|
|
494
|
+
font-size:12px;color:#9CA3AF;text-align:center">
|
|
495
|
+
Brela — Silent AI code attribution — data is local, no network calls were made
|
|
496
|
+
</footer>
|
|
497
|
+
|
|
498
|
+
</main>
|
|
499
|
+
|
|
500
|
+
<script>
|
|
501
|
+
${chartJs}
|
|
502
|
+
</script>
|
|
503
|
+
<script>
|
|
504
|
+
window.addEventListener('load', function() {
|
|
505
|
+
var DATA = ${metricsJson};
|
|
506
|
+
|
|
507
|
+
// ── Set canvas sizes BEFORE chart init — prevents ResizeObserver loop ──
|
|
508
|
+
var trendEl = document.getElementById('trendChart');
|
|
509
|
+
var toolEl = document.getElementById('toolChart');
|
|
510
|
+
trendEl.width = 580;
|
|
511
|
+
trendEl.height = 280;
|
|
512
|
+
trendEl.style.height = '280px';
|
|
513
|
+
toolEl.width = 320;
|
|
514
|
+
toolEl.height = 280;
|
|
515
|
+
toolEl.style.height = '280px';
|
|
516
|
+
|
|
517
|
+
// ── Tool colour map (Intercom palette) ────────────────────────────────────
|
|
518
|
+
var TOOL_COLORS = {
|
|
519
|
+
'Copilot': '#1F8EFA',
|
|
520
|
+
'Copilot Agent': '#3B82F6',
|
|
521
|
+
'Copilot CLI': '#60A5FA',
|
|
522
|
+
'Claude Code': '#F97316',
|
|
523
|
+
'Claude Code Agent': '#EA580C',
|
|
524
|
+
'Cursor': '#8B5CF6',
|
|
525
|
+
'Cursor Agent': '#7C3AED',
|
|
526
|
+
'ChatGPT Paste': '#10B981',
|
|
527
|
+
'Codeium': '#06B6D4',
|
|
528
|
+
'Cline': '#F59E0B',
|
|
529
|
+
'Aider': '#84CC16',
|
|
530
|
+
'Continue': '#14B8A6',
|
|
531
|
+
'AI Agent': '#6B7280',
|
|
532
|
+
'Unknown': '#9CA3AF'
|
|
533
|
+
};
|
|
534
|
+
var FALLBACK = ['#1F8EFA','#F97316','#8B5CF6','#10B981','#F59E0B','#9CA3AF','#06B6D4'];
|
|
535
|
+
|
|
536
|
+
// ── Daily trend — line chart ──────────────────────────────────────────────
|
|
537
|
+
var ctx1 = trendEl.getContext('2d');
|
|
538
|
+
new Chart(ctx1, {
|
|
539
|
+
type: 'line',
|
|
540
|
+
data: {
|
|
541
|
+
labels: DATA.perDayTrend.map(function(d) { return d.date; }),
|
|
542
|
+
datasets: [
|
|
543
|
+
{
|
|
544
|
+
label: 'Human Lines',
|
|
545
|
+
data: DATA.perDayTrend.map(function(d) { return d.humanLines; }),
|
|
546
|
+
borderColor: '#1F8EFA',
|
|
547
|
+
backgroundColor: 'rgba(31,142,250,.08)',
|
|
548
|
+
fill: true, tension: 0.35, pointRadius: 3,
|
|
549
|
+
pointBackgroundColor: '#1F8EFA'
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
label: 'AI Lines',
|
|
553
|
+
data: DATA.perDayTrend.map(function(d) { return d.aiLines; }),
|
|
554
|
+
borderColor: '#F59E0B',
|
|
555
|
+
backgroundColor: 'rgba(245,158,11,.08)',
|
|
556
|
+
fill: true, tension: 0.35, pointRadius: 3,
|
|
557
|
+
pointBackgroundColor: '#F59E0B'
|
|
558
|
+
}
|
|
559
|
+
]
|
|
560
|
+
},
|
|
561
|
+
options: {
|
|
562
|
+
responsive: false,
|
|
563
|
+
animation: false,
|
|
564
|
+
plugins: {
|
|
565
|
+
legend: { position: 'top', labels: { color: '#6B7280', boxWidth: 12, padding: 16 } }
|
|
566
|
+
},
|
|
567
|
+
scales: {
|
|
568
|
+
y: { beginAtZero: true, grid: { color: '#F3F4F6' }, ticks: { color: '#6B7280' } },
|
|
569
|
+
x: { grid: { display: false }, ticks: { color: '#6B7280', maxTicksLimit: 8 } }
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// ── Tool breakdown — doughnut ─────────────────────────────────────────────
|
|
575
|
+
var tools = Object.keys(DATA.perToolBreakdown);
|
|
576
|
+
var toolColors = tools.map(function(t, i) {
|
|
577
|
+
return TOOL_COLORS[t] || FALLBACK[i % FALLBACK.length];
|
|
578
|
+
});
|
|
579
|
+
var ctx2 = toolEl.getContext('2d');
|
|
580
|
+
new Chart(ctx2, {
|
|
581
|
+
type: 'doughnut',
|
|
582
|
+
data: {
|
|
583
|
+
labels: tools,
|
|
584
|
+
datasets: [{
|
|
585
|
+
data: tools.map(function(t) { return DATA.perToolBreakdown[t]; }),
|
|
586
|
+
backgroundColor: toolColors,
|
|
587
|
+
borderWidth: 2,
|
|
588
|
+
borderColor: '#ffffff'
|
|
589
|
+
}]
|
|
590
|
+
},
|
|
591
|
+
options: {
|
|
592
|
+
responsive: false,
|
|
593
|
+
animation: false,
|
|
594
|
+
cutout: '65%',
|
|
595
|
+
plugins: {
|
|
596
|
+
legend: { position: 'bottom', labels: { color: '#6B7280', boxWidth: 12, padding: 12 } },
|
|
597
|
+
tooltip: {
|
|
598
|
+
callbacks: {
|
|
599
|
+
label: function(ctx) { return ' ' + ctx.label + ': ' + ctx.raw.toFixed(1) + '%'; }
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// ── Sortable heatmap table ────────────────────────────────────────────────
|
|
607
|
+
var table = document.getElementById('heatmapTable');
|
|
608
|
+
if (table) {
|
|
609
|
+
var tbody = table.querySelector('tbody');
|
|
610
|
+
var lastCol = 'pct', lastDir = -1;
|
|
611
|
+
table.querySelectorAll('th[data-col]').forEach(function(th) {
|
|
612
|
+
th.addEventListener('click', function() {
|
|
613
|
+
var col = th.getAttribute('data-col');
|
|
614
|
+
if (col === lastCol) { lastDir *= -1; } else { lastCol = col; lastDir = -1; }
|
|
615
|
+
table.querySelectorAll('th').forEach(function(h) {
|
|
616
|
+
h.classList.remove('sorted-asc', 'sorted-desc');
|
|
617
|
+
});
|
|
618
|
+
th.classList.add(lastDir === -1 ? 'sorted-desc' : 'sorted-asc');
|
|
619
|
+
var rows = Array.from(tbody.querySelectorAll('tr'));
|
|
620
|
+
rows.sort(function(a, b) {
|
|
621
|
+
var av = a.getAttribute('data-' + col) || '';
|
|
622
|
+
var bv = b.getAttribute('data-' + col) || '';
|
|
623
|
+
var an = parseFloat(av), bn = parseFloat(bv);
|
|
624
|
+
if (!isNaN(an) && !isNaN(bn)) return (an - bn) * lastDir;
|
|
625
|
+
return av.localeCompare(bv) * lastDir;
|
|
626
|
+
});
|
|
627
|
+
rows.forEach(function(r) { tbody.appendChild(r); });
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
var pctTh = table.querySelector('th[data-col="pct"]');
|
|
631
|
+
if (pctTh) pctTh.classList.add('sorted-desc');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// ── Export button ─────────────────────────────────────────────────────────
|
|
635
|
+
document.getElementById('exportBtn').addEventListener('click', function() {
|
|
636
|
+
var blob = new Blob([JSON.stringify(DATA, null, 2)], { type: 'application/json' });
|
|
637
|
+
var url = URL.createObjectURL(blob);
|
|
638
|
+
var a = document.createElement('a');
|
|
639
|
+
a.href = url;
|
|
640
|
+
a.download = 'brela-report-' + DATA.dateTo + '.json';
|
|
641
|
+
a.click();
|
|
642
|
+
URL.revokeObjectURL(url);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// ── Dismissible warning banner ────────────────────────────────────────────
|
|
646
|
+
var dismissBtn = document.getElementById('dismissWarning');
|
|
647
|
+
if (dismissBtn) {
|
|
648
|
+
dismissBtn.addEventListener('click', function() {
|
|
649
|
+
var banner = document.getElementById('warningBanner');
|
|
650
|
+
if (banner) banner.style.display = 'none';
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
</script>
|
|
655
|
+
</body>
|
|
656
|
+
</html>`;
|
|
657
|
+
}
|
|
658
|
+
function escHtml(s) {
|
|
659
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
660
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
661
|
+
}
|
|
662
|
+
// ── JSON format ───────────────────────────────────────────────────────────────
|
|
663
|
+
function generateJson(m) {
|
|
664
|
+
// perFileHeatmap keeps Maps-as-objects; fileLines is already stripped (not in ReportMetrics)
|
|
665
|
+
return JSON.stringify(m, null, 2);
|
|
666
|
+
}
|
|
667
|
+
// ── Command factory ───────────────────────────────────────────────────────────
|
|
668
|
+
export function reportCommand() {
|
|
669
|
+
return new Command('report')
|
|
670
|
+
.description('Generate an AI attribution report')
|
|
671
|
+
.option('--days <n>', 'analyse last N days', '30')
|
|
672
|
+
.option('--output <path>', 'output HTML file path', './brela-report.html')
|
|
673
|
+
.option('--format <fmt>', 'output format: html | json', 'html')
|
|
674
|
+
.option('--repo <path>', 'project root to analyse', process.cwd())
|
|
675
|
+
.action(async (opts) => {
|
|
676
|
+
const projectRoot = path.resolve(opts.repo);
|
|
677
|
+
const days = Math.max(1, parseInt(opts.days, 10) || 30);
|
|
678
|
+
const format = opts.format === 'json' ? 'json' : 'html';
|
|
679
|
+
const brelaDir = path.join(projectRoot, '.brela');
|
|
680
|
+
if (!fs.existsSync(brelaDir)) {
|
|
681
|
+
console.log(`No .brela/ directory found in ${projectRoot}.\n\n` +
|
|
682
|
+
`Run "brela init" to set up attribution tracking, then:\n` +
|
|
683
|
+
` • Use your editor — the VS Code extension will log AI insertions\n` +
|
|
684
|
+
` • Run "brela daemon start" to enable shell-based tracking\n` +
|
|
685
|
+
` • Commit code — hooks will record AI-attributed commits\n\n` +
|
|
686
|
+
`After collecting a few days of data, run "brela report" again.`);
|
|
687
|
+
// Exit 0 — not an error, just no data yet
|
|
688
|
+
throw new BrelaExit(0);
|
|
689
|
+
}
|
|
690
|
+
let metrics;
|
|
691
|
+
try {
|
|
692
|
+
metrics = await computeMetrics(projectRoot, days);
|
|
693
|
+
}
|
|
694
|
+
catch (err) {
|
|
695
|
+
logError(projectRoot, err);
|
|
696
|
+
throw new BrelaExit(1, `Brela report failed: ${String(err)}`);
|
|
697
|
+
}
|
|
698
|
+
if (format === 'json') {
|
|
699
|
+
process.stdout.write(generateJson(metrics) + '\n');
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
const chartJs = loadChartJs();
|
|
704
|
+
const html = generateHtml(metrics, chartJs);
|
|
705
|
+
const outPath = path.resolve(opts.output);
|
|
706
|
+
fs.writeFileSync(outPath, html, 'utf8');
|
|
707
|
+
const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(0);
|
|
708
|
+
console.log(`Report written to ${outPath} (${sizeKb} KB)`);
|
|
709
|
+
if (metrics.insufficientData) {
|
|
710
|
+
console.log(' ⚠ Less than 3 days of data — results may not be representative.');
|
|
711
|
+
}
|
|
712
|
+
if (metrics.backfillCount > 0) {
|
|
713
|
+
console.log(` ↩ ${metrics.backfillCount} entries backfilled from git co-author trailers.`);
|
|
714
|
+
}
|
|
715
|
+
if (metrics.unreviewedAiCommits.length > 0) {
|
|
716
|
+
console.log(` ⚑ ${metrics.unreviewedAiCommits.length} unreviewed commits with >60% AI attribution.`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
logError(projectRoot, err);
|
|
721
|
+
throw new BrelaExit(1, `Brela: failed to write report — ${String(err)}`);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
//# sourceMappingURL=report.js.map
|