guito 1.0.5 → 1.0.7
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 +24 -0
- package/CONTRIBUTION.md +10 -10
- package/README.md +66 -1
- package/bin/ai-review.js +749 -0
- package/bin/commit-message.js +146 -0
- package/bin/guito-server.js +461 -79
- package/bin/ui/browser/index.html +1 -1
- package/bin/ui/browser/main-2QNMM6BC.js +67 -0
- package/{guito-1.0.5.vsix → guito-1.0.7.vsix} +0 -0
- package/package.json +1 -1
- package/bin/ui/browser/main-LODECJV3.js +0 -67
package/bin/ai-review.js
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const SEVERITIES = ['blocker', 'concern', 'suggestion', 'nit'];
|
|
6
|
+
export const DEFAULT_AI_REVIEW_CONFIG = {
|
|
7
|
+
enabled: false,
|
|
8
|
+
scope: 'reviewer',
|
|
9
|
+
pollMinutes: 30,
|
|
10
|
+
includeDrafts: false,
|
|
11
|
+
claudePath: '',
|
|
12
|
+
model: '',
|
|
13
|
+
commitMessageModel: 'haiku',
|
|
14
|
+
timeoutSeconds: 600,
|
|
15
|
+
maxDiffChars: 300000,
|
|
16
|
+
instructions: '',
|
|
17
|
+
};
|
|
18
|
+
/** Normalizes a partial config (settings file or VS Code) onto the defaults. */
|
|
19
|
+
export function resolveAiReviewConfig(...sources) {
|
|
20
|
+
const config = { ...DEFAULT_AI_REVIEW_CONFIG };
|
|
21
|
+
for (const source of sources) {
|
|
22
|
+
if (!source)
|
|
23
|
+
continue;
|
|
24
|
+
if (typeof source.enabled === 'boolean')
|
|
25
|
+
config.enabled = source.enabled;
|
|
26
|
+
if (source.scope === 'reviewer' || source.scope === 'mine' || source.scope === 'all') {
|
|
27
|
+
config.scope = source.scope;
|
|
28
|
+
}
|
|
29
|
+
if (Number.isFinite(source.pollMinutes)) {
|
|
30
|
+
config.pollMinutes = Math.min(Math.max(Number(source.pollMinutes), 1), 240);
|
|
31
|
+
}
|
|
32
|
+
if (typeof source.includeDrafts === 'boolean')
|
|
33
|
+
config.includeDrafts = source.includeDrafts;
|
|
34
|
+
if (typeof source.claudePath === 'string' && source.claudePath.trim()) {
|
|
35
|
+
config.claudePath = source.claudePath.trim();
|
|
36
|
+
}
|
|
37
|
+
if (typeof source.model === 'string') {
|
|
38
|
+
config.model = source.model.trim();
|
|
39
|
+
}
|
|
40
|
+
if (typeof source.commitMessageModel === 'string') {
|
|
41
|
+
config.commitMessageModel = source.commitMessageModel.trim();
|
|
42
|
+
}
|
|
43
|
+
if (Number.isFinite(source.timeoutSeconds)) {
|
|
44
|
+
config.timeoutSeconds = Math.min(Math.max(Number(source.timeoutSeconds), 30), 3600);
|
|
45
|
+
}
|
|
46
|
+
if (Number.isFinite(source.maxDiffChars)) {
|
|
47
|
+
config.maxDiffChars = Math.min(Math.max(Number(source.maxDiffChars), 2000), 2000000);
|
|
48
|
+
}
|
|
49
|
+
if (typeof source.instructions === 'string')
|
|
50
|
+
config.instructions = source.instructions;
|
|
51
|
+
}
|
|
52
|
+
return config;
|
|
53
|
+
}
|
|
54
|
+
/** Locations npm and the standalone installer put the CLI in. */
|
|
55
|
+
function claudeCandidates() {
|
|
56
|
+
const home = homedir();
|
|
57
|
+
const candidates = [
|
|
58
|
+
join(home, '.claude', 'local', 'claude'),
|
|
59
|
+
join(home, '.local', 'bin', 'claude'),
|
|
60
|
+
];
|
|
61
|
+
if (process.platform === 'win32') {
|
|
62
|
+
const appData = process.env.APPDATA;
|
|
63
|
+
if (appData)
|
|
64
|
+
candidates.push(join(appData, 'npm', 'claude.cmd'));
|
|
65
|
+
candidates.push(join(home, '.local', 'bin', 'claude.exe'));
|
|
66
|
+
}
|
|
67
|
+
return candidates;
|
|
68
|
+
}
|
|
69
|
+
/** Descending version order, so an upgraded extension wins without a setting. */
|
|
70
|
+
function compareVersions(left, right) {
|
|
71
|
+
for (let index = 0; index < Math.max(left.length, right.length); index++) {
|
|
72
|
+
const difference = (right[index] ?? 0) - (left[index] ?? 0);
|
|
73
|
+
if (difference)
|
|
74
|
+
return difference;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The Claude Code VS Code extension ships its own CLI under a version-stamped
|
|
80
|
+
* folder that is on no PATH. That is the usual install for anyone running
|
|
81
|
+
* Claude Code in VS Code, which is exactly who this feature is for, so the
|
|
82
|
+
* extension folders are searched before giving up on PATH. The -server folders
|
|
83
|
+
* cover Remote SSH, Dev Containers and Codespaces.
|
|
84
|
+
*/
|
|
85
|
+
async function bundledClaudeCandidates() {
|
|
86
|
+
const { readdir } = await import('node:fs/promises');
|
|
87
|
+
const home = homedir();
|
|
88
|
+
const binary = process.platform === 'win32' ? 'claude.exe' : 'claude';
|
|
89
|
+
const found = [];
|
|
90
|
+
for (const root of ['.vscode', '.vscode-insiders', '.vscode-server', '.vscode-server-insiders']) {
|
|
91
|
+
const extensions = join(home, root, 'extensions');
|
|
92
|
+
let entries;
|
|
93
|
+
try {
|
|
94
|
+
entries = await readdir(extensions);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (!entry.startsWith('anthropic.claude-code-'))
|
|
101
|
+
continue;
|
|
102
|
+
const version = (entry.match(/(\d+)\.(\d+)\.(\d+)/)?.slice(1) ?? []).map(Number);
|
|
103
|
+
found.push({
|
|
104
|
+
path: join(extensions, entry, 'resources', 'native-binary', binary),
|
|
105
|
+
version,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return found
|
|
110
|
+
.sort((left, right) => compareVersions(left.version, right.version))
|
|
111
|
+
.map((entry) => entry.path);
|
|
112
|
+
}
|
|
113
|
+
/** Resolves the executable to spawn: the setting, then the usual install paths. */
|
|
114
|
+
export async function resolveClaudeCommand(configured) {
|
|
115
|
+
if (configured.trim())
|
|
116
|
+
return configured.trim();
|
|
117
|
+
const { access } = await import('node:fs/promises');
|
|
118
|
+
const candidates = [...claudeCandidates(), ...(await bundledClaudeCandidates())];
|
|
119
|
+
for (const candidate of candidates) {
|
|
120
|
+
try {
|
|
121
|
+
await access(candidate);
|
|
122
|
+
return candidate;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Try the next known location.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Fall back to PATH; the spawn error names the missing executable.
|
|
129
|
+
return process.platform === 'win32' ? 'claude.cmd' : 'claude';
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Runs the Claude Code CLI in print mode. The prompt goes over stdin because a
|
|
133
|
+
* pull request diff is far larger than the Windows command line allows.
|
|
134
|
+
* Exported because the commit message drafter runs the CLI the same way.
|
|
135
|
+
*/
|
|
136
|
+
export const runClaudeCli = (prompt, { command, args, cwd, timeoutMs, signal }) => new Promise((resolve, reject) => {
|
|
137
|
+
const child = spawn(command, args, {
|
|
138
|
+
cwd,
|
|
139
|
+
windowsHide: true,
|
|
140
|
+
// .cmd shims on Windows are not executables and need the shell.
|
|
141
|
+
shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(command),
|
|
142
|
+
});
|
|
143
|
+
let stdout = '';
|
|
144
|
+
let stderr = '';
|
|
145
|
+
let settled = false;
|
|
146
|
+
const finish = (error) => {
|
|
147
|
+
if (settled)
|
|
148
|
+
return;
|
|
149
|
+
settled = true;
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
signal?.removeEventListener('abort', onAbort);
|
|
152
|
+
if (error)
|
|
153
|
+
reject(error);
|
|
154
|
+
else
|
|
155
|
+
resolve(stdout);
|
|
156
|
+
};
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
child.kill();
|
|
159
|
+
finish(new Error(`The review timed out after ${Math.round(timeoutMs / 1000)}s.`));
|
|
160
|
+
}, timeoutMs);
|
|
161
|
+
const onAbort = () => {
|
|
162
|
+
child.kill();
|
|
163
|
+
finish(new Error('The review was cancelled.'));
|
|
164
|
+
};
|
|
165
|
+
if (signal) {
|
|
166
|
+
if (signal.aborted)
|
|
167
|
+
onAbort();
|
|
168
|
+
else
|
|
169
|
+
signal.addEventListener('abort', onAbort);
|
|
170
|
+
}
|
|
171
|
+
child.stdout.on('data', (chunk) => {
|
|
172
|
+
stdout += String(chunk);
|
|
173
|
+
});
|
|
174
|
+
child.stderr.on('data', (chunk) => {
|
|
175
|
+
stderr += String(chunk);
|
|
176
|
+
});
|
|
177
|
+
child.on('error', (error) => {
|
|
178
|
+
finish(new Error(error.code === 'ENOENT'
|
|
179
|
+
? `Claude Code was not found at "${command}". Install it, or set guito.aiReview.claudePath.`
|
|
180
|
+
: `Claude Code could not be started: ${error.message}`));
|
|
181
|
+
});
|
|
182
|
+
child.on('close', (code) => {
|
|
183
|
+
if (code === 0)
|
|
184
|
+
return finish(null);
|
|
185
|
+
const detail = stderr.trim() || stdout.trim();
|
|
186
|
+
finish(new Error(`Claude Code exited with code ${code}${detail ? `: ${detail}` : '.'}`));
|
|
187
|
+
});
|
|
188
|
+
child.stdin.on('error', () => {
|
|
189
|
+
// A CLI that exits early closes stdin; the close handler reports why.
|
|
190
|
+
});
|
|
191
|
+
child.stdin.end(prompt);
|
|
192
|
+
});
|
|
193
|
+
/** Unwraps `--output-format json` and tolerates a fenced or chatty reply. */
|
|
194
|
+
export function parseModelReview(raw) {
|
|
195
|
+
let text = raw.trim();
|
|
196
|
+
if (!text)
|
|
197
|
+
throw new Error('Claude Code returned an empty response.');
|
|
198
|
+
try {
|
|
199
|
+
const envelope = JSON.parse(text);
|
|
200
|
+
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
|
|
201
|
+
if (envelope.is_error === true) {
|
|
202
|
+
throw new Error(String(envelope.result ?? 'Claude Code reported an error.'));
|
|
203
|
+
}
|
|
204
|
+
if (typeof envelope.result === 'string')
|
|
205
|
+
text = envelope.result.trim();
|
|
206
|
+
else if (Array.isArray(envelope.findings)) {
|
|
207
|
+
return {
|
|
208
|
+
summary: String(envelope.summary ?? ''),
|
|
209
|
+
findings: envelope.findings,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
// Not the CLI envelope; fall through and read the text as the review.
|
|
216
|
+
if (error?.message?.startsWith('Claude Code'))
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
220
|
+
if (fenced)
|
|
221
|
+
text = fenced[1].trim();
|
|
222
|
+
const start = text.indexOf('{');
|
|
223
|
+
const end = text.lastIndexOf('}');
|
|
224
|
+
if (start === -1 || end <= start) {
|
|
225
|
+
throw new Error(`Claude Code did not return a JSON review: ${text.slice(0, 300)}`);
|
|
226
|
+
}
|
|
227
|
+
let review;
|
|
228
|
+
try {
|
|
229
|
+
review = JSON.parse(text.slice(start, end + 1));
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
throw new Error(`Claude Code returned malformed JSON: ${text.slice(start, start + 300)}`);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
summary: String(review?.summary ?? ''),
|
|
236
|
+
findings: Array.isArray(review?.findings) ? review.findings : [],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/** Identity of a finding across re-reviews: same place, same point. */
|
|
240
|
+
function fingerprint(file, title) {
|
|
241
|
+
const normalized = title
|
|
242
|
+
.toLowerCase()
|
|
243
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
244
|
+
.trim();
|
|
245
|
+
return createHash('sha1').update(`${file}\u0000${normalized}`).digest('hex').slice(0, 16);
|
|
246
|
+
}
|
|
247
|
+
/** Renders one file's diff with the line numbers the model must cite. */
|
|
248
|
+
function renderFileDiff(diff, budget) {
|
|
249
|
+
const header = `--- FILE: ${diff.path}${diff.oldPath && diff.oldPath !== diff.path ? ` (renamed from ${diff.oldPath})` : ''} [${diff.status}] ---`;
|
|
250
|
+
if (diff.binary)
|
|
251
|
+
return `${header}\n(binary file, not reviewable)\n`;
|
|
252
|
+
const rows = [header];
|
|
253
|
+
let used = header.length;
|
|
254
|
+
let truncated = false;
|
|
255
|
+
for (const line of diff.lines) {
|
|
256
|
+
let row;
|
|
257
|
+
if (line.type === 'hunk')
|
|
258
|
+
row = ` ${line.text}`;
|
|
259
|
+
else if (line.type === 'add')
|
|
260
|
+
row = `${String(line.newLine ?? '').padStart(6)} + ${line.text}`;
|
|
261
|
+
else if (line.type === 'del')
|
|
262
|
+
row = `${''.padStart(6)} - ${line.text}`;
|
|
263
|
+
else
|
|
264
|
+
row = `${String(line.newLine ?? '').padStart(6)} ${line.text}`;
|
|
265
|
+
if (used + row.length > budget) {
|
|
266
|
+
truncated = true;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
rows.push(row);
|
|
270
|
+
used += row.length + 1;
|
|
271
|
+
}
|
|
272
|
+
if (truncated)
|
|
273
|
+
rows.push(' (diff truncated: file too large to review in full)');
|
|
274
|
+
return `${rows.join('\n')}\n`;
|
|
275
|
+
}
|
|
276
|
+
/** Existing discussion, so the model neither repeats it nor talks past it. */
|
|
277
|
+
function renderThreads(threads) {
|
|
278
|
+
const relevant = threads.filter((thread) => thread.comments.length);
|
|
279
|
+
if (!relevant.length)
|
|
280
|
+
return 'None.';
|
|
281
|
+
return relevant
|
|
282
|
+
.map((thread) => {
|
|
283
|
+
const where = thread.filePath
|
|
284
|
+
? `${thread.filePath}${thread.line ? `:${thread.line}` : ''}`
|
|
285
|
+
: 'pull request';
|
|
286
|
+
const body = thread.comments
|
|
287
|
+
.map((comment) => ` ${comment.author.name}: ${comment.content.replace(/\s+/g, ' ')}`)
|
|
288
|
+
.join('\n');
|
|
289
|
+
return ` [${thread.status}] ${where}\n${body}`;
|
|
290
|
+
})
|
|
291
|
+
.join('\n');
|
|
292
|
+
}
|
|
293
|
+
const REVIEW_CONTRACT = `Reply with one JSON object and nothing else:
|
|
294
|
+
{"summary": "<one or two sentences on the change as a whole>",
|
|
295
|
+
"findings": [
|
|
296
|
+
{"file": "<path exactly as given in the diff header, or \\"\\" for the change as a whole>",
|
|
297
|
+
"line": <the right-hand line number shown in the diff, or null>,
|
|
298
|
+
"endLine": <last line of the range, or null>,
|
|
299
|
+
"severity": "blocker" | "concern" | "suggestion" | "nit",
|
|
300
|
+
"title": "<a short stable label, reused verbatim if you raise this point again later>",
|
|
301
|
+
"comment": "<the review comment, in markdown, addressed to the author>"}
|
|
302
|
+
]}
|
|
303
|
+
|
|
304
|
+
Rules:
|
|
305
|
+
- Only cite a line number that appears as an added or context line in that file's diff. Never guess.
|
|
306
|
+
- Report problems you can justify from the diff: bugs, broken edge cases, security holes,
|
|
307
|
+
data loss, race conditions, API misuse, missing error handling, tests that do not test
|
|
308
|
+
what they claim. Also flag clear violations of the surrounding code's conventions.
|
|
309
|
+
- Do not comment on formatting a formatter handles, do not restate what the code does,
|
|
310
|
+
and do not ask for changes the diff already makes.
|
|
311
|
+
- An empty findings array is the right answer for a clean change. Say so in the summary.
|
|
312
|
+
- Answer only from the diff below. Do not use tools and do not ask questions.`;
|
|
313
|
+
/** Builds the review prompt for one pass over one pull request. */
|
|
314
|
+
export function buildReviewPrompt(input) {
|
|
315
|
+
const { pullRequest, diffs, threads, previous, sinceCommit } = input;
|
|
316
|
+
const incremental = Boolean(sinceCommit);
|
|
317
|
+
const perFile = Math.max(2000, Math.floor(input.maxDiffChars / Math.max(diffs.length, 1)));
|
|
318
|
+
const body = diffs.map((diff) => renderFileDiff(diff, perFile)).join('\n');
|
|
319
|
+
const raised = previous.filter((finding) => finding.status !== 'dismissed');
|
|
320
|
+
const dismissed = previous.filter((finding) => finding.status === 'dismissed');
|
|
321
|
+
const sections = [
|
|
322
|
+
'You are reviewing an Azure DevOps pull request as a careful senior engineer on this team.',
|
|
323
|
+
'',
|
|
324
|
+
`PULL REQUEST #${pullRequest.id}: ${pullRequest.title}`,
|
|
325
|
+
`Author: ${pullRequest.author}`,
|
|
326
|
+
`Merging ${pullRequest.sourceBranch} into ${pullRequest.targetBranch}`,
|
|
327
|
+
pullRequest.description ? `\nDescription:\n${pullRequest.description}` : '',
|
|
328
|
+
'',
|
|
329
|
+
incremental
|
|
330
|
+
? `THIS IS A FOLLOW-UP REVIEW. You already reviewed this pull request at commit ${sinceCommit.slice(0, 8)}.\n` +
|
|
331
|
+
`The diff below covers the files touched since then, at the current commit ${pullRequest.sourceCommit.slice(0, 8)}.\n` +
|
|
332
|
+
'Review the new work. Raise a point you made before only if the new code makes it worse\n' +
|
|
333
|
+
'or the author changed it incorrectly; reuse the same title when you do.'
|
|
334
|
+
: `This is the first review, at commit ${pullRequest.sourceCommit.slice(0, 8)}.`,
|
|
335
|
+
'',
|
|
336
|
+
'POINTS YOU ALREADY RAISED (do not repeat these):',
|
|
337
|
+
raised.length
|
|
338
|
+
? raised
|
|
339
|
+
.map((finding) => ` - [${finding.severity}] ${finding.file || 'pull request'}${finding.line ? `:${finding.line}` : ''} — ${finding.title}`)
|
|
340
|
+
.join('\n')
|
|
341
|
+
: ' None.',
|
|
342
|
+
'',
|
|
343
|
+
'POINTS THE USER DISMISSED (never raise these again):',
|
|
344
|
+
dismissed.length
|
|
345
|
+
? dismissed
|
|
346
|
+
.map((finding) => ` - ${finding.file || 'pull request'} — ${finding.title}`)
|
|
347
|
+
.join('\n')
|
|
348
|
+
: ' None.',
|
|
349
|
+
'',
|
|
350
|
+
'EXISTING COMMENT THREADS ON THE PULL REQUEST:',
|
|
351
|
+
renderThreads(threads),
|
|
352
|
+
'',
|
|
353
|
+
input.skipped.length ? `NOT SHOWN (binary or unreviewable): ${input.skipped.join(', ')}\n` : '',
|
|
354
|
+
input.instructions.trim() ? `TEAM INSTRUCTIONS:\n${input.instructions.trim()}\n` : '',
|
|
355
|
+
REVIEW_CONTRACT,
|
|
356
|
+
'',
|
|
357
|
+
'DIFF',
|
|
358
|
+
'====',
|
|
359
|
+
body || '(no reviewable changes)',
|
|
360
|
+
];
|
|
361
|
+
return sections.filter((section) => section !== '').join('\n');
|
|
362
|
+
}
|
|
363
|
+
const emptyState = (pullRequestId, title = '') => ({
|
|
364
|
+
pullRequestId,
|
|
365
|
+
title,
|
|
366
|
+
reviewedCommit: '',
|
|
367
|
+
reviewedAt: '',
|
|
368
|
+
passes: 0,
|
|
369
|
+
status: 'idle',
|
|
370
|
+
error: '',
|
|
371
|
+
summary: '',
|
|
372
|
+
findings: [],
|
|
373
|
+
});
|
|
374
|
+
export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG, repositoryPath = process.cwd()) {
|
|
375
|
+
let config = initialConfig;
|
|
376
|
+
const runModel = deps.runModel ?? runClaudeCli;
|
|
377
|
+
const now = deps.now ?? (() => new Date());
|
|
378
|
+
const log = deps.log ?? (() => { });
|
|
379
|
+
const listeners = new Set();
|
|
380
|
+
const running = new Set();
|
|
381
|
+
let timer;
|
|
382
|
+
let activePoll = null;
|
|
383
|
+
let pollAbort;
|
|
384
|
+
// One review at a time: each one spawns a CLI and burns Azure DevOps calls.
|
|
385
|
+
let queue = Promise.resolve();
|
|
386
|
+
const serialize = (action) => {
|
|
387
|
+
const result = queue.then(action);
|
|
388
|
+
queue = result.catch(() => { });
|
|
389
|
+
return result;
|
|
390
|
+
};
|
|
391
|
+
// Store writes are read-modify-write, so they are serialized too.
|
|
392
|
+
let storeQueue = Promise.resolve();
|
|
393
|
+
const mutateStore = (action) => {
|
|
394
|
+
const result = storeQueue.then(async () => {
|
|
395
|
+
const store = await deps.readStore();
|
|
396
|
+
if (!store.pullRequests)
|
|
397
|
+
store.pullRequests = {};
|
|
398
|
+
const value = await action(store);
|
|
399
|
+
await deps.writeStore(store);
|
|
400
|
+
return value;
|
|
401
|
+
});
|
|
402
|
+
storeQueue = result.catch(() => { });
|
|
403
|
+
return result;
|
|
404
|
+
};
|
|
405
|
+
const stateOf = async (pullRequestId) => {
|
|
406
|
+
const store = await deps.readStore();
|
|
407
|
+
return store.pullRequests?.[String(pullRequestId)] ?? null;
|
|
408
|
+
};
|
|
409
|
+
/** Turns the model's reply into findings, dropping repeats and bad anchors. */
|
|
410
|
+
const normalizeFindings = (raw, known, diffs, commit) => {
|
|
411
|
+
const anchors = new Map();
|
|
412
|
+
for (const diff of diffs) {
|
|
413
|
+
const lines = new Set();
|
|
414
|
+
for (const line of diff.lines) {
|
|
415
|
+
if (typeof line.newLine === 'number')
|
|
416
|
+
lines.add(line.newLine);
|
|
417
|
+
}
|
|
418
|
+
anchors.set(diff.path, lines);
|
|
419
|
+
}
|
|
420
|
+
const seen = new Set(known.map((finding) => finding.id));
|
|
421
|
+
const accepted = [];
|
|
422
|
+
for (const entry of raw) {
|
|
423
|
+
const title = String(entry?.title ?? '').trim();
|
|
424
|
+
const comment = String(entry?.comment ?? '').trim();
|
|
425
|
+
if (!title || !comment)
|
|
426
|
+
continue;
|
|
427
|
+
const file = String(entry?.file ?? '')
|
|
428
|
+
.trim()
|
|
429
|
+
.replace(/^\//, '');
|
|
430
|
+
// A path the diff never mentioned cannot be anchored; post it PR-level.
|
|
431
|
+
const inDiff = anchors.has(file);
|
|
432
|
+
const rawLine = Number(entry?.line);
|
|
433
|
+
const line = inDiff && Number.isInteger(rawLine) && anchors.get(file).has(rawLine) ? rawLine : null;
|
|
434
|
+
const rawEnd = Number(entry?.endLine);
|
|
435
|
+
const endLine = line !== null &&
|
|
436
|
+
Number.isInteger(rawEnd) &&
|
|
437
|
+
rawEnd >= line &&
|
|
438
|
+
anchors.get(file).has(rawEnd)
|
|
439
|
+
? rawEnd
|
|
440
|
+
: null;
|
|
441
|
+
const severity = SEVERITIES.includes(entry?.severity)
|
|
442
|
+
? entry.severity
|
|
443
|
+
: 'concern';
|
|
444
|
+
// Identity follows the file the model named, not where the comment can
|
|
445
|
+
// be anchored: a point raised again for a file the follow-up diff no
|
|
446
|
+
// longer shows is still the same point.
|
|
447
|
+
const id = fingerprint(file, title);
|
|
448
|
+
if (seen.has(id))
|
|
449
|
+
continue;
|
|
450
|
+
seen.add(id);
|
|
451
|
+
accepted.push({
|
|
452
|
+
id,
|
|
453
|
+
file: inDiff ? file : '',
|
|
454
|
+
line,
|
|
455
|
+
endLine,
|
|
456
|
+
severity,
|
|
457
|
+
title,
|
|
458
|
+
comment,
|
|
459
|
+
commit,
|
|
460
|
+
status: 'pending',
|
|
461
|
+
createdAt: now().toISOString(),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return accepted.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
|
|
465
|
+
};
|
|
466
|
+
const runReview = async (pullRequestId, options = {}) => {
|
|
467
|
+
// A pass the poller cancelled before this review's turn must not start.
|
|
468
|
+
if (options.signal?.aborted) {
|
|
469
|
+
return (await stateOf(pullRequestId)) ?? emptyState(pullRequestId);
|
|
470
|
+
}
|
|
471
|
+
const pullRequest = await deps.loadPullRequest(pullRequestId);
|
|
472
|
+
const previousState = (await stateOf(pullRequestId)) ?? emptyState(pullRequestId);
|
|
473
|
+
// A per-review model choice wins over the configured one. An alias such
|
|
474
|
+
// as "opus" always means the latest model of that family Claude Code has.
|
|
475
|
+
const model = typeof options.model === 'string' && options.model.trim()
|
|
476
|
+
? options.model.trim()
|
|
477
|
+
: config.model;
|
|
478
|
+
// A different model earns a fresh look at code already reviewed at this
|
|
479
|
+
// commit; when new commits exist, they alone are reviewed with it.
|
|
480
|
+
const freshLook = (previousState.model ?? '') !== model &&
|
|
481
|
+
previousState.reviewedCommit === pullRequest.sourceCommit;
|
|
482
|
+
if (!options.force &&
|
|
483
|
+
!freshLook &&
|
|
484
|
+
previousState.reviewedCommit &&
|
|
485
|
+
previousState.reviewedCommit === pullRequest.sourceCommit) {
|
|
486
|
+
return previousState;
|
|
487
|
+
}
|
|
488
|
+
running.add(pullRequestId);
|
|
489
|
+
await mutateStore((store) => {
|
|
490
|
+
store.pullRequests[String(pullRequestId)] = {
|
|
491
|
+
...previousState,
|
|
492
|
+
title: pullRequest.title,
|
|
493
|
+
status: 'running',
|
|
494
|
+
error: '',
|
|
495
|
+
};
|
|
496
|
+
});
|
|
497
|
+
try {
|
|
498
|
+
// A re-review looks only at what changed since the last reviewed commit.
|
|
499
|
+
const sinceCommit = !options.force && previousState.reviewedCommit !== pullRequest.sourceCommit
|
|
500
|
+
? previousState.reviewedCommit
|
|
501
|
+
: '';
|
|
502
|
+
const changes = await deps.loadChanges(pullRequestId, sinceCommit || undefined);
|
|
503
|
+
const [threads, diffs] = await Promise.all([
|
|
504
|
+
deps.loadThreads(pullRequestId),
|
|
505
|
+
Promise.all(changes.map((file) => deps.loadFileDiff(pullRequestId, file))),
|
|
506
|
+
]);
|
|
507
|
+
const reviewable = diffs.filter((diff) => !diff.binary && diff.lines.length);
|
|
508
|
+
const skipped = diffs
|
|
509
|
+
.filter((diff) => diff.binary || !diff.lines.length)
|
|
510
|
+
.map((diff) => diff.path);
|
|
511
|
+
let summary = '';
|
|
512
|
+
let accepted = [];
|
|
513
|
+
if (reviewable.length) {
|
|
514
|
+
const prompt = buildReviewPrompt({
|
|
515
|
+
pullRequest,
|
|
516
|
+
diffs: reviewable,
|
|
517
|
+
threads,
|
|
518
|
+
previous: previousState.findings,
|
|
519
|
+
sinceCommit,
|
|
520
|
+
skipped,
|
|
521
|
+
maxDiffChars: config.maxDiffChars,
|
|
522
|
+
instructions: config.instructions,
|
|
523
|
+
});
|
|
524
|
+
const command = await resolveClaudeCommand(config.claudePath);
|
|
525
|
+
const args = ['-p', '--output-format', 'json', ...(model ? ['--model', model] : [])];
|
|
526
|
+
log(`AI review: reviewing pull request ${pullRequestId} with ${command}${model ? ` (${model})` : ''}`);
|
|
527
|
+
const raw = await runModel(prompt, {
|
|
528
|
+
command,
|
|
529
|
+
args,
|
|
530
|
+
cwd: repositoryPath,
|
|
531
|
+
timeoutMs: config.timeoutSeconds * 1000,
|
|
532
|
+
signal: options.signal,
|
|
533
|
+
});
|
|
534
|
+
const review = parseModelReview(raw);
|
|
535
|
+
summary = review.summary;
|
|
536
|
+
accepted = normalizeFindings(review.findings, previousState.findings, reviewable, pullRequest.sourceCommit);
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
summary = 'No reviewable text changes in this pull request.';
|
|
540
|
+
}
|
|
541
|
+
const next = await mutateStore((store) => {
|
|
542
|
+
const current = store.pullRequests[String(pullRequestId)] ?? previousState;
|
|
543
|
+
const state = {
|
|
544
|
+
...current,
|
|
545
|
+
title: pullRequest.title,
|
|
546
|
+
reviewedCommit: pullRequest.sourceCommit,
|
|
547
|
+
model,
|
|
548
|
+
reviewedAt: now().toISOString(),
|
|
549
|
+
passes: current.passes + 1,
|
|
550
|
+
status: 'idle',
|
|
551
|
+
error: '',
|
|
552
|
+
summary,
|
|
553
|
+
findings: [...current.findings, ...accepted],
|
|
554
|
+
};
|
|
555
|
+
store.pullRequests[String(pullRequestId)] = state;
|
|
556
|
+
return state;
|
|
557
|
+
});
|
|
558
|
+
log(`AI review: pull request ${pullRequestId} produced ${accepted.length} new finding(s).`);
|
|
559
|
+
if (accepted.length) {
|
|
560
|
+
for (const listener of listeners)
|
|
561
|
+
listener(next);
|
|
562
|
+
}
|
|
563
|
+
return next;
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
const cancelled = options.signal?.aborted === true;
|
|
567
|
+
const message = error?.message ? String(error.message) : String(error);
|
|
568
|
+
log(cancelled
|
|
569
|
+
? `AI review: pull request ${pullRequestId} was cancelled.`
|
|
570
|
+
: `AI review: pull request ${pullRequestId} failed: ${message}`);
|
|
571
|
+
return mutateStore((store) => {
|
|
572
|
+
const current = store.pullRequests[String(pullRequestId)] ?? previousState;
|
|
573
|
+
// A cancelled review is not a failure: the pull request is left as it
|
|
574
|
+
// was, so switching the reviewer back on reviews the same commit.
|
|
575
|
+
const state = cancelled
|
|
576
|
+
? { ...current, status: 'idle', error: '' }
|
|
577
|
+
: {
|
|
578
|
+
...current,
|
|
579
|
+
status: 'error',
|
|
580
|
+
error: message,
|
|
581
|
+
errorCommit: pullRequest.sourceCommit,
|
|
582
|
+
};
|
|
583
|
+
store.pullRequests[String(pullRequestId)] = state;
|
|
584
|
+
return state;
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
finally {
|
|
588
|
+
running.delete(pullRequestId);
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
/** One pass over the pull requests in scope; reviews those with new code. */
|
|
592
|
+
const runPoll = async (signal) => {
|
|
593
|
+
const reviewed = [];
|
|
594
|
+
try {
|
|
595
|
+
const pullRequests = (await deps.listPullRequests()).filter(wanted);
|
|
596
|
+
const store = await deps.readStore();
|
|
597
|
+
for (const pullRequest of pullRequests) {
|
|
598
|
+
if (signal?.aborted)
|
|
599
|
+
break;
|
|
600
|
+
if (!pullRequest.sourceCommit)
|
|
601
|
+
continue;
|
|
602
|
+
const state = store.pullRequests?.[String(pullRequest.id)];
|
|
603
|
+
// Already reviewed at this commit, or already failed at it: a broken
|
|
604
|
+
// setup must not spin, so the retry waits for the next commit.
|
|
605
|
+
if (state?.reviewedCommit === pullRequest.sourceCommit)
|
|
606
|
+
continue;
|
|
607
|
+
if (state?.status === 'error' && state.errorCommit === pullRequest.sourceCommit)
|
|
608
|
+
continue;
|
|
609
|
+
const next = await serialize(() => runReview(pullRequest.id, { signal }));
|
|
610
|
+
if (!signal?.aborted && next.status !== 'error')
|
|
611
|
+
reviewed.push(pullRequest.id);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
log(`AI review: poll failed: ${error?.message ?? error}`);
|
|
616
|
+
}
|
|
617
|
+
return reviewed;
|
|
618
|
+
};
|
|
619
|
+
const wanted = (pullRequest) => {
|
|
620
|
+
if (pullRequest.isDraft && !config.includeDrafts)
|
|
621
|
+
return false;
|
|
622
|
+
if (config.scope === 'all')
|
|
623
|
+
return true;
|
|
624
|
+
if (config.scope === 'mine')
|
|
625
|
+
return pullRequest.isMine;
|
|
626
|
+
return pullRequest.isReviewer;
|
|
627
|
+
};
|
|
628
|
+
const clearTimer = () => {
|
|
629
|
+
if (timer)
|
|
630
|
+
clearInterval(timer);
|
|
631
|
+
timer = undefined;
|
|
632
|
+
};
|
|
633
|
+
const reviewer = {
|
|
634
|
+
updateConfig(next) {
|
|
635
|
+
const wasEnabled = config.enabled;
|
|
636
|
+
const interval = config.pollMinutes;
|
|
637
|
+
config = next;
|
|
638
|
+
if (!next.enabled)
|
|
639
|
+
reviewer.stop();
|
|
640
|
+
else if (!wasEnabled || interval !== next.pollMinutes)
|
|
641
|
+
reviewer.start();
|
|
642
|
+
},
|
|
643
|
+
config: () => config,
|
|
644
|
+
state: stateOf,
|
|
645
|
+
async overview() {
|
|
646
|
+
const store = await deps.readStore();
|
|
647
|
+
const pullRequests = Object.values(store.pullRequests ?? {}).map((state) => ({
|
|
648
|
+
pullRequestId: state.pullRequestId,
|
|
649
|
+
title: state.title,
|
|
650
|
+
status: running.has(state.pullRequestId) ? 'running' : state.status,
|
|
651
|
+
pending: state.findings.filter((finding) => finding.status === 'pending').length,
|
|
652
|
+
reviewedCommit: state.reviewedCommit,
|
|
653
|
+
reviewedAt: state.reviewedAt,
|
|
654
|
+
}));
|
|
655
|
+
return { enabled: config.enabled, running: running.size, pullRequests };
|
|
656
|
+
},
|
|
657
|
+
review: (pullRequestId, options) => serialize(() => runReview(pullRequestId, options)),
|
|
658
|
+
async post(pullRequestId, findingIds) {
|
|
659
|
+
const state = await stateOf(pullRequestId);
|
|
660
|
+
if (!state)
|
|
661
|
+
throw new Error(`Pull request ${pullRequestId} has not been reviewed yet.`);
|
|
662
|
+
const wanted = new Set(findingIds);
|
|
663
|
+
const posting = state.findings.filter((finding) => finding.status === 'pending' && wanted.has(finding.id));
|
|
664
|
+
if (!posting.length)
|
|
665
|
+
throw new Error('No pending findings were selected.');
|
|
666
|
+
const posted = new Map();
|
|
667
|
+
for (const finding of posting) {
|
|
668
|
+
// Azure only anchors a thread to a file when it also has a line, so a
|
|
669
|
+
// file-level finding names its file in the comment instead of losing it.
|
|
670
|
+
const anchored = Boolean(finding.file && finding.line);
|
|
671
|
+
const where = !anchored && finding.file ? ` · \`${finding.file}\`` : '';
|
|
672
|
+
const header = `**${finding.severity}** · ${finding.title}${where}`;
|
|
673
|
+
const result = await deps.postComment(pullRequestId, {
|
|
674
|
+
content: `${header}\n\n${finding.comment}`,
|
|
675
|
+
filePath: anchored ? finding.file : undefined,
|
|
676
|
+
line: finding.line ?? undefined,
|
|
677
|
+
endLine: finding.endLine ?? undefined,
|
|
678
|
+
});
|
|
679
|
+
posted.set(finding.id, result.threadId);
|
|
680
|
+
}
|
|
681
|
+
return mutateStore((store) => {
|
|
682
|
+
const current = store.pullRequests[String(pullRequestId)] ?? state;
|
|
683
|
+
const next = {
|
|
684
|
+
...current,
|
|
685
|
+
findings: current.findings.map((finding) => posted.has(finding.id)
|
|
686
|
+
? {
|
|
687
|
+
...finding,
|
|
688
|
+
status: 'posted',
|
|
689
|
+
threadId: posted.get(finding.id),
|
|
690
|
+
resolvedAt: now().toISOString(),
|
|
691
|
+
}
|
|
692
|
+
: finding),
|
|
693
|
+
};
|
|
694
|
+
store.pullRequests[String(pullRequestId)] = next;
|
|
695
|
+
return next;
|
|
696
|
+
});
|
|
697
|
+
},
|
|
698
|
+
async dismiss(pullRequestId, findingIds) {
|
|
699
|
+
const wanted = new Set(findingIds);
|
|
700
|
+
return mutateStore((store) => {
|
|
701
|
+
const current = store.pullRequests[String(pullRequestId)];
|
|
702
|
+
if (!current)
|
|
703
|
+
throw new Error(`Pull request ${pullRequestId} has not been reviewed yet.`);
|
|
704
|
+
const next = {
|
|
705
|
+
...current,
|
|
706
|
+
findings: current.findings.map((finding) => finding.status === 'pending' && wanted.has(finding.id)
|
|
707
|
+
? { ...finding, status: 'dismissed', resolvedAt: now().toISOString() }
|
|
708
|
+
: finding),
|
|
709
|
+
};
|
|
710
|
+
store.pullRequests[String(pullRequestId)] = next;
|
|
711
|
+
return next;
|
|
712
|
+
});
|
|
713
|
+
},
|
|
714
|
+
async forget(pullRequestId) {
|
|
715
|
+
await mutateStore((store) => {
|
|
716
|
+
delete store.pullRequests[String(pullRequestId)];
|
|
717
|
+
});
|
|
718
|
+
},
|
|
719
|
+
poll() {
|
|
720
|
+
if (!config.enabled)
|
|
721
|
+
return Promise.resolve([]);
|
|
722
|
+
// A caller asking for a poll while one runs waits for that pass rather
|
|
723
|
+
// than being told, misleadingly, that there was nothing to review.
|
|
724
|
+
activePoll ?? (activePoll = runPoll((pollAbort = new AbortController()).signal).finally(() => {
|
|
725
|
+
activePoll = null;
|
|
726
|
+
pollAbort = undefined;
|
|
727
|
+
}));
|
|
728
|
+
return activePoll;
|
|
729
|
+
},
|
|
730
|
+
start() {
|
|
731
|
+
clearTimer();
|
|
732
|
+
if (!config.enabled)
|
|
733
|
+
return;
|
|
734
|
+
timer = setInterval(() => void reviewer.poll(), config.pollMinutes * 60000);
|
|
735
|
+
timer.unref?.();
|
|
736
|
+
},
|
|
737
|
+
stop() {
|
|
738
|
+
clearTimer();
|
|
739
|
+
// Switching the reviewer off also cancels the pass in flight: the CLI
|
|
740
|
+
// run is aborted and the pass's remaining pull requests are skipped.
|
|
741
|
+
pollAbort?.abort();
|
|
742
|
+
},
|
|
743
|
+
onFindings(listener) {
|
|
744
|
+
listeners.add(listener);
|
|
745
|
+
return () => listeners.delete(listener);
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
return reviewer;
|
|
749
|
+
}
|