ai-commit-reviewer 1.0.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 +350 -0
- package/bin/cli.js +190 -0
- package/bin/dashboard.js +505 -0
- package/bin/install.js +111 -0
- package/bin/uninstall.js +44 -0
- package/package.json +58 -0
- package/src/analyzer/api.js +197 -0
- package/src/analyzer/git.js +158 -0
- package/src/analyzer/prompt.js +408 -0
- package/src/config.js +93 -0
- package/src/index.js +94 -0
- package/src/memory/index.js +101 -0
- package/src/output/colors.js +85 -0
package/bin/dashboard.js
ADDED
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
// ── bin/dashboard.js ──────────────────────────────────────
|
|
2
|
+
// Generates a beautiful HTML dashboard from review history.
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { readLog, loadPatterns } = require("../src/memory");
|
|
7
|
+
const config = require("../src/config");
|
|
8
|
+
|
|
9
|
+
function generateDashboard() {
|
|
10
|
+
const log = readLog();
|
|
11
|
+
const patterns = loadPatterns();
|
|
12
|
+
|
|
13
|
+
if (!log.length) {
|
|
14
|
+
console.log("No reviews yet. Make a commit first!");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const totalReviews = patterns.total_commits_reviewed || 0;
|
|
19
|
+
const blockedCount = log.filter((l) => l.had_blockers).length;
|
|
20
|
+
const blockRate = totalReviews ? Math.round((blockedCount / totalReviews) * 100) : 0;
|
|
21
|
+
|
|
22
|
+
// Category frequency
|
|
23
|
+
const catFreq = {};
|
|
24
|
+
for (const entry of log) {
|
|
25
|
+
for (const cat of entry.categories || []) {
|
|
26
|
+
catFreq[cat] = (catFreq[cat] || 0) + 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const topCategories = Object.entries(catFreq)
|
|
31
|
+
.sort((a, b) => b[1] - a[1])
|
|
32
|
+
.slice(0, 8);
|
|
33
|
+
|
|
34
|
+
// Recent reviews (last 20)
|
|
35
|
+
const recent = [...log].reverse().slice(0, 20);
|
|
36
|
+
|
|
37
|
+
// Trend data — commits per day with blocker flag
|
|
38
|
+
const byDay = {};
|
|
39
|
+
for (const entry of log) {
|
|
40
|
+
const day = entry.timestamp?.slice(0, 10) || "unknown";
|
|
41
|
+
if (!byDay[day]) byDay[day] = { total: 0, blocked: 0 };
|
|
42
|
+
byDay[day].total++;
|
|
43
|
+
if (entry.had_blockers) byDay[day].blocked++;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const trendDays = Object.entries(byDay)
|
|
47
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
48
|
+
.slice(-14);
|
|
49
|
+
|
|
50
|
+
const blindSpots = patterns.team_blind_spots || [];
|
|
51
|
+
const learnedCount = patterns.recurring_issues?.length || 0;
|
|
52
|
+
|
|
53
|
+
const catColors = {
|
|
54
|
+
SECURITY: "#FF4757",
|
|
55
|
+
CRASH: "#FF6B35",
|
|
56
|
+
ANR: "#FF9F43",
|
|
57
|
+
"NON-FATAL":"#FFC312",
|
|
58
|
+
PERF: "#7B68EE",
|
|
59
|
+
SUGGEST: "#3D9BE9",
|
|
60
|
+
DUPLICATE: "#2ED573",
|
|
61
|
+
STYLE: "#A4B0BE",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const html = `<!DOCTYPE html>
|
|
65
|
+
<html lang="en">
|
|
66
|
+
<head>
|
|
67
|
+
<meta charset="UTF-8">
|
|
68
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
69
|
+
<title>AI Senior Dev Reviewer — Dashboard</title>
|
|
70
|
+
<style>
|
|
71
|
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Syne:wght@400;600;800&display=swap');
|
|
72
|
+
|
|
73
|
+
:root {
|
|
74
|
+
--bg: #0D0F14;
|
|
75
|
+
--surface: #151820;
|
|
76
|
+
--surface2: #1C2030;
|
|
77
|
+
--border: #252A3A;
|
|
78
|
+
--text: #E8EAF0;
|
|
79
|
+
--muted: #5A6080;
|
|
80
|
+
--accent: #4F6EF7;
|
|
81
|
+
--green: #2ED573;
|
|
82
|
+
--red: #FF4757;
|
|
83
|
+
--yellow: #FFC312;
|
|
84
|
+
--mono: 'JetBrains Mono', monospace;
|
|
85
|
+
--sans: 'Syne', sans-serif;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
89
|
+
|
|
90
|
+
body {
|
|
91
|
+
background: var(--bg);
|
|
92
|
+
color: var(--text);
|
|
93
|
+
font-family: var(--sans);
|
|
94
|
+
min-height: 100vh;
|
|
95
|
+
padding: 40px 24px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.page { max-width: 1100px; margin: 0 auto; }
|
|
99
|
+
|
|
100
|
+
/* Header */
|
|
101
|
+
.header {
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: flex-start;
|
|
104
|
+
justify-content: space-between;
|
|
105
|
+
margin-bottom: 48px;
|
|
106
|
+
padding-bottom: 32px;
|
|
107
|
+
border-bottom: 1px solid var(--border);
|
|
108
|
+
}
|
|
109
|
+
.logo-mark {
|
|
110
|
+
font-family: var(--mono);
|
|
111
|
+
font-size: 11px;
|
|
112
|
+
color: var(--accent);
|
|
113
|
+
letter-spacing: 3px;
|
|
114
|
+
text-transform: uppercase;
|
|
115
|
+
margin-bottom: 8px;
|
|
116
|
+
}
|
|
117
|
+
h1 {
|
|
118
|
+
font-size: 36px;
|
|
119
|
+
font-weight: 800;
|
|
120
|
+
line-height: 1.1;
|
|
121
|
+
letter-spacing: -1px;
|
|
122
|
+
}
|
|
123
|
+
h1 span { color: var(--accent); }
|
|
124
|
+
.subtitle {
|
|
125
|
+
margin-top: 8px;
|
|
126
|
+
color: var(--muted);
|
|
127
|
+
font-size: 14px;
|
|
128
|
+
font-family: var(--mono);
|
|
129
|
+
}
|
|
130
|
+
.last-run {
|
|
131
|
+
text-align: right;
|
|
132
|
+
font-family: var(--mono);
|
|
133
|
+
font-size: 11px;
|
|
134
|
+
color: var(--muted);
|
|
135
|
+
}
|
|
136
|
+
.live-dot {
|
|
137
|
+
display: inline-block;
|
|
138
|
+
width: 6px; height: 6px;
|
|
139
|
+
background: var(--green);
|
|
140
|
+
border-radius: 50%;
|
|
141
|
+
margin-right: 6px;
|
|
142
|
+
animation: pulse 2s infinite;
|
|
143
|
+
}
|
|
144
|
+
@keyframes pulse {
|
|
145
|
+
0%, 100% { opacity: 1; }
|
|
146
|
+
50% { opacity: 0.3; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* Stat cards */
|
|
150
|
+
.stats {
|
|
151
|
+
display: grid;
|
|
152
|
+
grid-template-columns: repeat(4, 1fr);
|
|
153
|
+
gap: 16px;
|
|
154
|
+
margin-bottom: 32px;
|
|
155
|
+
}
|
|
156
|
+
.stat {
|
|
157
|
+
background: var(--surface);
|
|
158
|
+
border: 1px solid var(--border);
|
|
159
|
+
border-radius: 12px;
|
|
160
|
+
padding: 24px 20px;
|
|
161
|
+
position: relative;
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
}
|
|
164
|
+
.stat::before {
|
|
165
|
+
content: '';
|
|
166
|
+
position: absolute;
|
|
167
|
+
top: 0; left: 0; right: 0;
|
|
168
|
+
height: 2px;
|
|
169
|
+
background: var(--accent-color, var(--accent));
|
|
170
|
+
}
|
|
171
|
+
.stat-label {
|
|
172
|
+
font-family: var(--mono);
|
|
173
|
+
font-size: 10px;
|
|
174
|
+
color: var(--muted);
|
|
175
|
+
letter-spacing: 2px;
|
|
176
|
+
text-transform: uppercase;
|
|
177
|
+
margin-bottom: 12px;
|
|
178
|
+
}
|
|
179
|
+
.stat-value {
|
|
180
|
+
font-size: 42px;
|
|
181
|
+
font-weight: 800;
|
|
182
|
+
line-height: 1;
|
|
183
|
+
color: var(--accent-color, var(--text));
|
|
184
|
+
}
|
|
185
|
+
.stat-sub {
|
|
186
|
+
font-family: var(--mono);
|
|
187
|
+
font-size: 11px;
|
|
188
|
+
color: var(--muted);
|
|
189
|
+
margin-top: 6px;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* Grid layout */
|
|
193
|
+
.grid-2 {
|
|
194
|
+
display: grid;
|
|
195
|
+
grid-template-columns: 1fr 1fr;
|
|
196
|
+
gap: 20px;
|
|
197
|
+
margin-bottom: 20px;
|
|
198
|
+
}
|
|
199
|
+
.grid-3 {
|
|
200
|
+
display: grid;
|
|
201
|
+
grid-template-columns: 2fr 1fr;
|
|
202
|
+
gap: 20px;
|
|
203
|
+
margin-bottom: 20px;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* Cards */
|
|
207
|
+
.card {
|
|
208
|
+
background: var(--surface);
|
|
209
|
+
border: 1px solid var(--border);
|
|
210
|
+
border-radius: 12px;
|
|
211
|
+
padding: 24px;
|
|
212
|
+
}
|
|
213
|
+
.card-title {
|
|
214
|
+
font-family: var(--mono);
|
|
215
|
+
font-size: 10px;
|
|
216
|
+
color: var(--muted);
|
|
217
|
+
letter-spacing: 2px;
|
|
218
|
+
text-transform: uppercase;
|
|
219
|
+
margin-bottom: 20px;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Bar chart */
|
|
223
|
+
.bar-chart { display: flex; flex-direction: column; gap: 10px; }
|
|
224
|
+
.bar-row { display: flex; align-items: center; gap: 12px; }
|
|
225
|
+
.bar-label {
|
|
226
|
+
font-family: var(--mono);
|
|
227
|
+
font-size: 11px;
|
|
228
|
+
color: var(--muted);
|
|
229
|
+
width: 90px;
|
|
230
|
+
flex-shrink: 0;
|
|
231
|
+
}
|
|
232
|
+
.bar-track {
|
|
233
|
+
flex: 1;
|
|
234
|
+
height: 8px;
|
|
235
|
+
background: var(--surface2);
|
|
236
|
+
border-radius: 4px;
|
|
237
|
+
overflow: hidden;
|
|
238
|
+
}
|
|
239
|
+
.bar-fill {
|
|
240
|
+
height: 100%;
|
|
241
|
+
border-radius: 4px;
|
|
242
|
+
transition: width 0.8s ease;
|
|
243
|
+
}
|
|
244
|
+
.bar-count {
|
|
245
|
+
font-family: var(--mono);
|
|
246
|
+
font-size: 11px;
|
|
247
|
+
color: var(--muted);
|
|
248
|
+
width: 24px;
|
|
249
|
+
text-align: right;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/* Trend chart */
|
|
253
|
+
.trend-chart {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: flex-end;
|
|
256
|
+
gap: 4px;
|
|
257
|
+
height: 80px;
|
|
258
|
+
padding-top: 8px;
|
|
259
|
+
}
|
|
260
|
+
.trend-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
|
261
|
+
.trend-bar { width: 100%; border-radius: 3px 3px 0 0; min-height: 2px; }
|
|
262
|
+
.trend-day {
|
|
263
|
+
font-family: var(--mono);
|
|
264
|
+
font-size: 8px;
|
|
265
|
+
color: var(--muted);
|
|
266
|
+
margin-top: 4px;
|
|
267
|
+
writing-mode: vertical-rl;
|
|
268
|
+
transform: rotate(180deg);
|
|
269
|
+
white-space: nowrap;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* Blind spots */
|
|
273
|
+
.blind-spots { display: flex; flex-direction: column; gap: 8px; }
|
|
274
|
+
.blind-spot {
|
|
275
|
+
display: flex;
|
|
276
|
+
align-items: flex-start;
|
|
277
|
+
gap: 10px;
|
|
278
|
+
padding: 10px 12px;
|
|
279
|
+
background: var(--surface2);
|
|
280
|
+
border-radius: 8px;
|
|
281
|
+
border-left: 3px solid var(--red);
|
|
282
|
+
}
|
|
283
|
+
.blind-spot-num {
|
|
284
|
+
font-family: var(--mono);
|
|
285
|
+
font-size: 10px;
|
|
286
|
+
color: var(--red);
|
|
287
|
+
flex-shrink: 0;
|
|
288
|
+
margin-top: 1px;
|
|
289
|
+
}
|
|
290
|
+
.blind-spot-text {
|
|
291
|
+
font-size: 12px;
|
|
292
|
+
color: var(--text);
|
|
293
|
+
line-height: 1.4;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* Recent log */
|
|
297
|
+
.log-table { width: 100%; border-collapse: collapse; }
|
|
298
|
+
.log-table th {
|
|
299
|
+
font-family: var(--mono);
|
|
300
|
+
font-size: 9px;
|
|
301
|
+
color: var(--muted);
|
|
302
|
+
letter-spacing: 2px;
|
|
303
|
+
text-transform: uppercase;
|
|
304
|
+
text-align: left;
|
|
305
|
+
padding: 0 12px 12px;
|
|
306
|
+
border-bottom: 1px solid var(--border);
|
|
307
|
+
}
|
|
308
|
+
.log-table td {
|
|
309
|
+
padding: 10px 12px;
|
|
310
|
+
font-family: var(--mono);
|
|
311
|
+
font-size: 11px;
|
|
312
|
+
color: var(--muted);
|
|
313
|
+
border-bottom: 1px solid var(--border);
|
|
314
|
+
vertical-align: top;
|
|
315
|
+
}
|
|
316
|
+
.log-table tr:last-child td { border-bottom: none; }
|
|
317
|
+
.log-table tr:hover td { background: var(--surface2); }
|
|
318
|
+
.status-badge {
|
|
319
|
+
display: inline-block;
|
|
320
|
+
padding: 2px 8px;
|
|
321
|
+
border-radius: 4px;
|
|
322
|
+
font-size: 10px;
|
|
323
|
+
font-weight: 600;
|
|
324
|
+
}
|
|
325
|
+
.blocked { background: rgba(255,71,87,0.15); color: var(--red); }
|
|
326
|
+
.allowed { background: rgba(46,213,115,0.15); color: var(--green); }
|
|
327
|
+
.issue-text { color: var(--text); max-width: 300px; }
|
|
328
|
+
.cat-pill {
|
|
329
|
+
display: inline-block;
|
|
330
|
+
padding: 1px 6px;
|
|
331
|
+
border-radius: 3px;
|
|
332
|
+
font-size: 9px;
|
|
333
|
+
margin: 1px;
|
|
334
|
+
font-weight: 600;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* Footer */
|
|
338
|
+
.footer {
|
|
339
|
+
margin-top: 48px;
|
|
340
|
+
padding-top: 24px;
|
|
341
|
+
border-top: 1px solid var(--border);
|
|
342
|
+
display: flex;
|
|
343
|
+
justify-content: space-between;
|
|
344
|
+
align-items: center;
|
|
345
|
+
}
|
|
346
|
+
.footer-left { font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
|
347
|
+
.footer-cmd {
|
|
348
|
+
font-family: var(--mono);
|
|
349
|
+
font-size: 11px;
|
|
350
|
+
color: var(--accent);
|
|
351
|
+
background: var(--surface2);
|
|
352
|
+
padding: 6px 12px;
|
|
353
|
+
border-radius: 6px;
|
|
354
|
+
border: 1px solid var(--border);
|
|
355
|
+
}
|
|
356
|
+
</style>
|
|
357
|
+
</head>
|
|
358
|
+
<body>
|
|
359
|
+
<div class="page">
|
|
360
|
+
|
|
361
|
+
<div class="header">
|
|
362
|
+
<div>
|
|
363
|
+
<div class="logo-mark">AI Senior Dev Reviewer</div>
|
|
364
|
+
<h1>Review <span>Dashboard</span></h1>
|
|
365
|
+
<div class="subtitle">Self-improving · ${learnedCount} patterns learned</div>
|
|
366
|
+
</div>
|
|
367
|
+
<div class="last-run">
|
|
368
|
+
<div><span class="live-dot"></span>Active</div>
|
|
369
|
+
<div style="margin-top:4px">Generated ${new Date().toLocaleString()}</div>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<!-- Stats -->
|
|
374
|
+
<div class="stats">
|
|
375
|
+
<div class="stat" style="--accent-color: var(--accent)">
|
|
376
|
+
<div class="stat-label">Total reviews</div>
|
|
377
|
+
<div class="stat-value">${totalReviews}</div>
|
|
378
|
+
<div class="stat-sub">commits reviewed</div>
|
|
379
|
+
</div>
|
|
380
|
+
<div class="stat" style="--accent-color: var(--red)">
|
|
381
|
+
<div class="stat-label">Blocked</div>
|
|
382
|
+
<div class="stat-value">${blockedCount}</div>
|
|
383
|
+
<div class="stat-sub">${blockRate}% block rate</div>
|
|
384
|
+
</div>
|
|
385
|
+
<div class="stat" style="--accent-color: var(--yellow)">
|
|
386
|
+
<div class="stat-label">Patterns learned</div>
|
|
387
|
+
<div class="stat-value">${learnedCount}</div>
|
|
388
|
+
<div class="stat-sub">recurring issues tracked</div>
|
|
389
|
+
</div>
|
|
390
|
+
<div class="stat" style="--accent-color: var(--green)">
|
|
391
|
+
<div class="stat-label">Clean commits</div>
|
|
392
|
+
<div class="stat-value">${totalReviews - blockedCount}</div>
|
|
393
|
+
<div class="stat-sub">passed without blockers</div>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<div class="grid-3">
|
|
398
|
+
<!-- Issue frequency -->
|
|
399
|
+
<div class="card">
|
|
400
|
+
<div class="card-title">Issue categories — all time</div>
|
|
401
|
+
<div class="bar-chart">
|
|
402
|
+
${topCategories.map(([cat, count]) => {
|
|
403
|
+
const pct = Math.round((count / totalReviews) * 100);
|
|
404
|
+
const col = catColors[cat] || "#5A6080";
|
|
405
|
+
return `<div class="bar-row">
|
|
406
|
+
<span class="bar-label">${cat}</span>
|
|
407
|
+
<div class="bar-track">
|
|
408
|
+
<div class="bar-fill" style="width:${pct}%;background:${col}"></div>
|
|
409
|
+
</div>
|
|
410
|
+
<span class="bar-count">${count}</span>
|
|
411
|
+
</div>`;
|
|
412
|
+
}).join("")}
|
|
413
|
+
${topCategories.length === 0 ? '<div style="color:var(--muted);font-family:var(--mono);font-size:12px">No data yet</div>' : ""}
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
|
|
417
|
+
<!-- Blind spots -->
|
|
418
|
+
<div class="card">
|
|
419
|
+
<div class="card-title">Team blind spots</div>
|
|
420
|
+
<div class="blind-spots">
|
|
421
|
+
${blindSpots.length
|
|
422
|
+
? blindSpots.map((s, i) => `
|
|
423
|
+
<div class="blind-spot">
|
|
424
|
+
<span class="blind-spot-num">${String(i + 1).padStart(2, "0")}</span>
|
|
425
|
+
<span class="blind-spot-text">${s}</span>
|
|
426
|
+
</div>`).join("")
|
|
427
|
+
: '<div style="color:var(--muted);font-family:var(--mono);font-size:12px">Still learning — make more commits</div>'
|
|
428
|
+
}
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<!-- Commit trend -->
|
|
434
|
+
<div class="card" style="margin-bottom:20px">
|
|
435
|
+
<div class="card-title">Commit trend — last 14 days</div>
|
|
436
|
+
<div class="trend-chart">
|
|
437
|
+
${trendDays.map(([day, data]) => {
|
|
438
|
+
const maxTotal = Math.max(...trendDays.map(([, d]) => d.total), 1);
|
|
439
|
+
const heightPct = Math.round((data.total / maxTotal) * 100);
|
|
440
|
+
const col = data.blocked > 0 ? "var(--red)" : "var(--accent)";
|
|
441
|
+
const label = day.slice(5); // MM-DD
|
|
442
|
+
return `<div class="trend-col">
|
|
443
|
+
<div class="trend-bar" style="height:${heightPct}%;background:${col}" title="${day}: ${data.total} reviews, ${data.blocked} blocked"></div>
|
|
444
|
+
<span class="trend-day">${label}</span>
|
|
445
|
+
</div>`;
|
|
446
|
+
}).join("")}
|
|
447
|
+
${trendDays.length === 0 ? '<div style="color:var(--muted);font-family:var(--mono);font-size:12px;padding:20px">No data yet</div>' : ""}
|
|
448
|
+
</div>
|
|
449
|
+
<div style="margin-top:12px;display:flex;gap:16px;font-family:var(--mono);font-size:10px;color:var(--muted)">
|
|
450
|
+
<span><span style="display:inline-block;width:8px;height:8px;background:var(--accent);border-radius:2px;margin-right:4px"></span>Clean</span>
|
|
451
|
+
<span><span style="display:inline-block;width:8px;height:8px;background:var(--red);border-radius:2px;margin-right:4px"></span>Blocked</span>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
|
|
455
|
+
<!-- Recent reviews -->
|
|
456
|
+
<div class="card">
|
|
457
|
+
<div class="card-title">Recent reviews</div>
|
|
458
|
+
<table class="log-table">
|
|
459
|
+
<thead>
|
|
460
|
+
<tr>
|
|
461
|
+
<th>Time</th>
|
|
462
|
+
<th>Status</th>
|
|
463
|
+
<th>Categories</th>
|
|
464
|
+
<th>Top issue</th>
|
|
465
|
+
<th>Files</th>
|
|
466
|
+
</tr>
|
|
467
|
+
</thead>
|
|
468
|
+
<tbody>
|
|
469
|
+
${recent.map((entry) => {
|
|
470
|
+
const time = entry.timestamp
|
|
471
|
+
? new Date(entry.timestamp).toLocaleString()
|
|
472
|
+
: "—";
|
|
473
|
+
const cats = (entry.categories || [])
|
|
474
|
+
.map((c) => `<span class="cat-pill" style="background:${(catColors[c] || "#5A6080")}22;color:${catColors[c] || "#5A6080"}">${c}</span>`)
|
|
475
|
+
.join("");
|
|
476
|
+
const files = Array.isArray(entry.files) ? entry.files.length : "—";
|
|
477
|
+
return `<tr>
|
|
478
|
+
<td>${time}</td>
|
|
479
|
+
<td><span class="status-badge ${entry.had_blockers ? "blocked" : "allowed"}">${entry.had_blockers ? "BLOCKED" : "ALLOWED"}</span></td>
|
|
480
|
+
<td>${cats || "—"}</td>
|
|
481
|
+
<td class="issue-text">${entry.top_issue || "—"}</td>
|
|
482
|
+
<td>${files}</td>
|
|
483
|
+
</tr>`;
|
|
484
|
+
}).join("")}
|
|
485
|
+
</tbody>
|
|
486
|
+
</table>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<div class="footer">
|
|
490
|
+
<div class="footer-left">AI Senior Dev Reviewer · self-improving since first commit</div>
|
|
491
|
+
<div class="footer-cmd">npm run dashboard</div>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
</div>
|
|
495
|
+
</body>
|
|
496
|
+
</html>`;
|
|
497
|
+
|
|
498
|
+
const outPath = config.dashboardFile;
|
|
499
|
+
fs.writeFileSync(outPath, html, "utf8");
|
|
500
|
+
|
|
501
|
+
console.log(`\n Dashboard generated: ${outPath}`);
|
|
502
|
+
console.log(` Open in browser: open ${outPath}\n`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
generateDashboard();
|
package/bin/install.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ── bin/install.js ────────────────────────────────────────
|
|
3
|
+
// Installs the pre-commit hook scoped to the sub-project
|
|
4
|
+
// you're currently in — not the monorepo root.
|
|
5
|
+
//
|
|
6
|
+
// cd newmecode/nextjs → hook scoped to nextjs/
|
|
7
|
+
// cd newmecode/mobile → hook scoped to mobile/
|
|
8
|
+
// cd newmecode/pos → hook scoped to pos/
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const { execSync } = require("child_process");
|
|
13
|
+
|
|
14
|
+
const C = {
|
|
15
|
+
green: "\x1b[32m",
|
|
16
|
+
yellow: "\x1b[33m",
|
|
17
|
+
red: "\x1b[31m",
|
|
18
|
+
cyan: "\x1b[36m",
|
|
19
|
+
bold: "\x1b[1m",
|
|
20
|
+
dim: "\x1b[2m",
|
|
21
|
+
reset: "\x1b[0m",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function run(cmd) {
|
|
25
|
+
try { return execSync(cmd, { encoding: "utf8", stdio: ["pipe","pipe","pipe"] }).trim(); }
|
|
26
|
+
catch { return ""; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Find the monorepo root (.git lives here) ──────────────
|
|
30
|
+
const gitRoot = run("git rev-parse --show-toplevel");
|
|
31
|
+
if (!gitRoot) {
|
|
32
|
+
console.error(`${C.red}✗ Not inside a git repository.${C.reset}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Current sub-project dir (where you ran the command) ───
|
|
37
|
+
const projectDir = process.cwd();
|
|
38
|
+
const projectName = path.basename(projectDir);
|
|
39
|
+
|
|
40
|
+
// ── Hook lives at the git root (git requires this) ────────
|
|
41
|
+
const hooksDir = path.join(gitRoot, ".git", "hooks");
|
|
42
|
+
const hookPath = path.join(hooksDir, "pre-commit");
|
|
43
|
+
|
|
44
|
+
if (!fs.existsSync(hooksDir)) fs.mkdirSync(hooksDir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
// ── Backup existing hook ──────────────────────────────────
|
|
47
|
+
if (fs.existsSync(hookPath)) {
|
|
48
|
+
const backup = hookPath + ".bak";
|
|
49
|
+
fs.copyFileSync(hookPath, backup);
|
|
50
|
+
console.log(`${C.yellow}⚠ Existing hook backed up → .git/hooks/pre-commit.bak${C.reset}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Reviewer entry point (absolute path) ─────────────────
|
|
54
|
+
const reviewerPath = path.resolve(__dirname, "..", "src", "index.js");
|
|
55
|
+
|
|
56
|
+
// ── Write the hook ────────────────────────────────────────
|
|
57
|
+
// The hook checks if the commit is being made from within
|
|
58
|
+
// the registered sub-project dir. If not, it skips silently.
|
|
59
|
+
// This means you can have different sub-projects each with
|
|
60
|
+
// their own scoped hook config without conflicts.
|
|
61
|
+
const hookContent = `#!/bin/sh
|
|
62
|
+
# AI Senior Dev Reviewer — scoped to: ${projectName}
|
|
63
|
+
# Installed from: ${projectDir}
|
|
64
|
+
# To skip: git commit --no-verify
|
|
65
|
+
|
|
66
|
+
# Only run when committing from within this sub-project
|
|
67
|
+
CURRENT_DIR=$(pwd)
|
|
68
|
+
PROJECT_DIR="${projectDir}"
|
|
69
|
+
|
|
70
|
+
if echo "$CURRENT_DIR" | grep -q "^$PROJECT_DIR"; then
|
|
71
|
+
node "${reviewerPath}" "$@"
|
|
72
|
+
else
|
|
73
|
+
# Different sub-project — skip silently
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
fs.writeFileSync(hookPath, hookContent, { mode: 0o755 });
|
|
79
|
+
|
|
80
|
+
// ── Bootstrap .ai-reviewer/ inside the sub-project ───────
|
|
81
|
+
const reviewerDir = path.join(projectDir, ".ai-reviewer");
|
|
82
|
+
if (!fs.existsSync(reviewerDir)) {
|
|
83
|
+
fs.mkdirSync(reviewerDir, { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const patternsPath = path.join(reviewerDir, "patterns.json");
|
|
87
|
+
if (!fs.existsSync(patternsPath)) {
|
|
88
|
+
fs.writeFileSync(patternsPath, JSON.stringify({
|
|
89
|
+
version: 2,
|
|
90
|
+
project: projectName,
|
|
91
|
+
total_commits_reviewed: 0,
|
|
92
|
+
recurring_issues: [],
|
|
93
|
+
team_blind_spots: [],
|
|
94
|
+
security_patterns_found: [],
|
|
95
|
+
crash_patterns_found: [],
|
|
96
|
+
last_reviewed: null,
|
|
97
|
+
}, null, 2));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Done ──────────────────────────────────────────────────
|
|
101
|
+
console.log(`\n${C.green}${C.bold} ✓ AI Reviewer installed for: ${projectName}${C.reset}\n`);
|
|
102
|
+
console.log(` Sub-project: ${projectDir}`);
|
|
103
|
+
console.log(` Git root: ${gitRoot}`);
|
|
104
|
+
console.log(` Hook: ${hookPath}`);
|
|
105
|
+
console.log(` Memory: ${reviewerDir}\n`);
|
|
106
|
+
console.log(` ${C.cyan}Make sure ${projectName}/.env contains OPENAI_API_KEY${C.reset}\n`);
|
|
107
|
+
console.log(` ${C.bold}Commands:${C.reset}`);
|
|
108
|
+
console.log(` ai-reviewer status — verify everything is set up`);
|
|
109
|
+
console.log(` ai-reviewer review — run manually on staged files`);
|
|
110
|
+
console.log(` ai-reviewer dashboard — open review history`);
|
|
111
|
+
console.log(` git commit --no-verify — skip for one commit\n`);
|
package/bin/uninstall.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ── bin/uninstall.js ──────────────────────────────────────
|
|
3
|
+
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { execSync } = require("child_process");
|
|
7
|
+
|
|
8
|
+
const C = { green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m", bold: "\x1b[1m", reset: "\x1b[0m" };
|
|
9
|
+
|
|
10
|
+
function run(cmd) {
|
|
11
|
+
try { return execSync(cmd, { encoding: "utf8" }).trim(); } catch { return ""; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const gitRoot = run("git rev-parse --show-toplevel");
|
|
15
|
+
if (!gitRoot) {
|
|
16
|
+
console.error(`${C.red}✗ Not inside a git repository.${C.reset}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const hookPath = path.join(gitRoot, ".git", "hooks", "pre-commit");
|
|
21
|
+
const backup = hookPath + ".bak";
|
|
22
|
+
|
|
23
|
+
if (!fs.existsSync(hookPath)) {
|
|
24
|
+
console.log(`${C.yellow}No pre-commit hook found — nothing to remove.${C.reset}`);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const content = fs.readFileSync(hookPath, "utf8");
|
|
29
|
+
if (!content.includes("AI Senior Dev Reviewer")) {
|
|
30
|
+
console.log(`${C.yellow}⚠ Pre-commit hook exists but was not installed by AI Reviewer.${C.reset}`);
|
|
31
|
+
console.log(` Remove it manually: ${hookPath}`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fs.unlinkSync(hookPath);
|
|
36
|
+
|
|
37
|
+
if (fs.existsSync(backup)) {
|
|
38
|
+
fs.copyFileSync(backup, hookPath);
|
|
39
|
+
fs.chmodSync(hookPath, 0o755);
|
|
40
|
+
fs.unlinkSync(backup);
|
|
41
|
+
console.log(`${C.green}${C.bold}✓ Hook removed. Previous hook restored from backup.${C.reset}`);
|
|
42
|
+
} else {
|
|
43
|
+
console.log(`${C.green}${C.bold}✓ AI Reviewer hook removed.${C.reset}`);
|
|
44
|
+
}
|