claude-git-hooks 2.20.0 → 2.30.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/CHANGELOG.md +155 -5
- package/CLAUDE.md +495 -63
- package/README.md +53 -5
- package/bin/claude-hooks +90 -0
- package/lib/cli-metadata.js +89 -2
- package/lib/commands/analyze-pr.js +678 -0
- package/lib/commands/back-merge.js +740 -0
- package/lib/commands/check-coupling.js +209 -0
- package/lib/commands/close-release.js +485 -0
- package/lib/commands/create-pr.js +62 -3
- package/lib/commands/create-release.js +600 -0
- package/lib/commands/diff-batch-info.js +7 -13
- package/lib/commands/help.js +72 -42
- package/lib/commands/install.js +1 -5
- package/lib/commands/revert-feature.js +436 -0
- package/lib/commands/setup-linear.js +96 -0
- package/lib/commands/shadow.js +654 -0
- package/lib/config.js +16 -2
- package/lib/hooks/pre-commit.js +8 -6
- package/lib/utils/authorization.js +429 -0
- package/lib/utils/claude-client.js +16 -5
- package/lib/utils/coupling-detector.js +133 -0
- package/lib/utils/diff-analysis-orchestrator.js +7 -14
- package/lib/utils/git-operations.js +480 -1
- package/lib/utils/github-api.js +358 -112
- package/lib/utils/judge.js +67 -8
- package/lib/utils/linear-connector.js +284 -0
- package/lib/utils/package-info.js +0 -1
- package/lib/utils/pr-statistics.js +85 -0
- package/lib/utils/token-store.js +161 -0
- package/package.json +69 -69
- package/templates/ANALYZE_PR.md +79 -0
- package/templates/config.advanced.example.json +44 -3
- package/templates/settings.local.example.json +2 -1
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: analyze-pr.js
|
|
3
|
+
* Purpose: Analyze a GitHub PR with team guidelines and post review comments
|
|
4
|
+
*
|
|
5
|
+
* Flow:
|
|
6
|
+
* 1. Parse GitHub PR URL
|
|
7
|
+
* 2. Fetch PR details + files via Octokit
|
|
8
|
+
* 3. Extract Linear ticket from title (optional enrichment)
|
|
9
|
+
* 4. Determine preset (labels → ticket → auto-detect → default)
|
|
10
|
+
* 5. Build prompt from ANALYZE_PR.md template + preset guidelines
|
|
11
|
+
* 6. Call Claude for analysis
|
|
12
|
+
* 7. Normalize categories + display results
|
|
13
|
+
* 8. Interactive comment workflow (confirm/skip each)
|
|
14
|
+
* 9. Post review via GitHub API
|
|
15
|
+
* 10. Record statistics
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getConfig } from '../config.js';
|
|
19
|
+
import {
|
|
20
|
+
parseGitHubPRUrl,
|
|
21
|
+
fetchPullRequest,
|
|
22
|
+
fetchPullRequestFiles,
|
|
23
|
+
createPullRequestReview,
|
|
24
|
+
getGitHubToken
|
|
25
|
+
} from '../utils/github-api.js';
|
|
26
|
+
import { executeClaudeWithRetry, extractJSON } from '../utils/claude-client.js';
|
|
27
|
+
import { loadPrompt } from '../utils/prompt-builder.js';
|
|
28
|
+
import { loadPreset, listPresets } from '../utils/preset-loader.js';
|
|
29
|
+
import { extractLinearTicketFromTitle, fetchTicket } from '../utils/linear-connector.js';
|
|
30
|
+
import { recordPRAnalysis } from '../utils/pr-statistics.js';
|
|
31
|
+
import { promptConfirmation, promptMenu } from '../utils/interactive-ui.js';
|
|
32
|
+
import { colors, error, info, warning, checkGitRepo } from './helpers.js';
|
|
33
|
+
import logger from '../utils/logger.js';
|
|
34
|
+
import path from 'path';
|
|
35
|
+
|
|
36
|
+
// ─── Category Normalization ─────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fuzzy alias mapping for category normalization
|
|
40
|
+
* Rationale per alias documented inline
|
|
41
|
+
*/
|
|
42
|
+
const CATEGORY_ALIASES = {
|
|
43
|
+
// Inline category aliases
|
|
44
|
+
bugs: 'bug',
|
|
45
|
+
error: 'bug',
|
|
46
|
+
errors: 'bug',
|
|
47
|
+
defect: 'bug',
|
|
48
|
+
sec: 'security',
|
|
49
|
+
vulnerability: 'security',
|
|
50
|
+
vuln: 'security',
|
|
51
|
+
perf: 'performance',
|
|
52
|
+
slow: 'performance',
|
|
53
|
+
complexity: 'hotspot',
|
|
54
|
+
complex: 'hotspot',
|
|
55
|
+
|
|
56
|
+
// General category aliases
|
|
57
|
+
ticket: 'ticket-alignment',
|
|
58
|
+
alignment: 'ticket-alignment',
|
|
59
|
+
'ticket-match': 'ticket-alignment',
|
|
60
|
+
'scope-creep': 'scope',
|
|
61
|
+
overscope: 'scope',
|
|
62
|
+
styling: 'style',
|
|
63
|
+
formatting: 'style',
|
|
64
|
+
'code-style': 'style',
|
|
65
|
+
'best-practice': 'good-practice',
|
|
66
|
+
'best-practices': 'good-practice',
|
|
67
|
+
practice: 'good-practice',
|
|
68
|
+
extension: 'extensibility',
|
|
69
|
+
scalability: 'extensibility',
|
|
70
|
+
logging: 'observability',
|
|
71
|
+
monitoring: 'observability',
|
|
72
|
+
visibility: 'observability', // "visibility" → "observability" per spec
|
|
73
|
+
docs: 'documentation',
|
|
74
|
+
doc: 'documentation',
|
|
75
|
+
tests: 'testing',
|
|
76
|
+
test: 'testing',
|
|
77
|
+
'test-coverage': 'testing'
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Normalize a category value to its canonical form
|
|
82
|
+
* @param {string} raw - Raw category from Claude response
|
|
83
|
+
* @param {string[]} allCategories - All valid canonical categories
|
|
84
|
+
* @param {Object} aliases - Alias mapping
|
|
85
|
+
* @returns {string} Canonical category or 'uncategorized'
|
|
86
|
+
*/
|
|
87
|
+
export const normalizeCategory = (raw, allCategories, aliases) => {
|
|
88
|
+
if (!raw || typeof raw !== 'string') return 'uncategorized';
|
|
89
|
+
|
|
90
|
+
const lower = raw.toLowerCase().trim();
|
|
91
|
+
|
|
92
|
+
// Direct match
|
|
93
|
+
if (allCategories.includes(lower)) return lower;
|
|
94
|
+
|
|
95
|
+
// Alias match
|
|
96
|
+
if (aliases[lower]) return aliases[lower];
|
|
97
|
+
|
|
98
|
+
logger.debug('analyze-pr - normalizeCategory', 'Unknown category', {
|
|
99
|
+
raw,
|
|
100
|
+
normalized: 'uncategorized'
|
|
101
|
+
});
|
|
102
|
+
return 'uncategorized';
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ─── Preset Auto-detection ──────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detect best preset from PR file extensions
|
|
109
|
+
* @param {Array<Object>} prFiles - PR files from GitHub API
|
|
110
|
+
* @returns {Promise<string>} Preset name or 'default'
|
|
111
|
+
*/
|
|
112
|
+
const detectPresetFromFiles = async (prFiles) => {
|
|
113
|
+
try {
|
|
114
|
+
const presetNames = await listPresets();
|
|
115
|
+
if (presetNames.length === 0) return 'default';
|
|
116
|
+
|
|
117
|
+
const prExtensions = prFiles
|
|
118
|
+
.map((f) => path.extname(f.filename).toLowerCase())
|
|
119
|
+
.filter(Boolean);
|
|
120
|
+
|
|
121
|
+
if (prExtensions.length === 0) return 'default';
|
|
122
|
+
|
|
123
|
+
let bestPreset = 'default';
|
|
124
|
+
let bestScore = 0;
|
|
125
|
+
|
|
126
|
+
for (const { name } of presetNames) {
|
|
127
|
+
try {
|
|
128
|
+
const { metadata } = await loadPreset(name);
|
|
129
|
+
const exts = metadata.fileExtensions || [];
|
|
130
|
+
const score = prExtensions.filter((ext) => exts.includes(ext)).length;
|
|
131
|
+
|
|
132
|
+
if (score > bestScore) {
|
|
133
|
+
bestScore = score;
|
|
134
|
+
bestPreset = name;
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// Skip invalid presets
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
logger.debug('analyze-pr - detectPresetFromFiles', 'Auto-detected preset', {
|
|
142
|
+
bestPreset,
|
|
143
|
+
bestScore
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return bestPreset;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
logger.debug('analyze-pr - detectPresetFromFiles', 'Detection failed', {
|
|
149
|
+
error: error.message
|
|
150
|
+
});
|
|
151
|
+
return 'default';
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ─── Preset Resolution ──────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Determine which preset to use, in priority order:
|
|
159
|
+
* 1. CLI --preset flag
|
|
160
|
+
* 2. PR labels matching preset names
|
|
161
|
+
* 3. Linear ticket labels matching preset names
|
|
162
|
+
* 4. Auto-detect from file extensions
|
|
163
|
+
* 5. 'default'
|
|
164
|
+
*
|
|
165
|
+
* @param {Object} options
|
|
166
|
+
* @param {string|null} options.cliPreset - --preset flag value
|
|
167
|
+
* @param {Array<Object>} options.prLabels - PR labels ({ name })
|
|
168
|
+
* @param {Array<string>} options.ticketLabels - Linear ticket labels
|
|
169
|
+
* @param {Array<Object>} options.prFiles - PR files from GitHub API
|
|
170
|
+
* @returns {Promise<string>} Resolved preset name
|
|
171
|
+
*/
|
|
172
|
+
const resolvePreset = async ({ cliPreset, prLabels, ticketLabels, prFiles }) => {
|
|
173
|
+
// 1. CLI override
|
|
174
|
+
if (cliPreset) {
|
|
175
|
+
logger.debug('analyze-pr - resolvePreset', 'Using CLI preset', { preset: cliPreset });
|
|
176
|
+
return cliPreset;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const presetCandidates = ['ai', 'backend', 'database', 'frontend', 'fullstack'];
|
|
180
|
+
|
|
181
|
+
// 2. PR labels
|
|
182
|
+
const prLabelNames = (prLabels || []).map((l) => l.name.toLowerCase());
|
|
183
|
+
for (const candidate of presetCandidates) {
|
|
184
|
+
if (prLabelNames.includes(candidate)) {
|
|
185
|
+
logger.debug('analyze-pr - resolvePreset', 'Matched from PR labels', {
|
|
186
|
+
preset: candidate
|
|
187
|
+
});
|
|
188
|
+
return candidate;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 3. Linear ticket labels
|
|
193
|
+
for (const label of ticketLabels || []) {
|
|
194
|
+
const lower = label.toLowerCase();
|
|
195
|
+
if (presetCandidates.includes(lower)) {
|
|
196
|
+
logger.debug('analyze-pr - resolvePreset', 'Matched from ticket labels', {
|
|
197
|
+
preset: lower
|
|
198
|
+
});
|
|
199
|
+
return lower;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 4. Auto-detect from file extensions
|
|
204
|
+
const detected = await detectPresetFromFiles(prFiles);
|
|
205
|
+
return detected;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// ─── Display Helpers ────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
const SEVERITY_ICONS = {
|
|
211
|
+
critical: '\u{1F534}',
|
|
212
|
+
major: '\u{1F7E0}',
|
|
213
|
+
minor: '\u{1F535}',
|
|
214
|
+
info: '\u{26AA}'
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Format a comment for display
|
|
219
|
+
* @param {Object} comment - Inline or general comment
|
|
220
|
+
* @param {number} index - 1-based index
|
|
221
|
+
* @param {boolean} isInline - Whether this is an inline comment
|
|
222
|
+
*/
|
|
223
|
+
const displayComment = (comment, index, isInline) => {
|
|
224
|
+
const icon = SEVERITY_ICONS[comment.severity] || '\u{26AA}';
|
|
225
|
+
const prefix = isInline ? `${comment.path}:${comment.line}` : 'General';
|
|
226
|
+
|
|
227
|
+
console.log(` ${icon} [${index}] [${comment.category}] ${prefix}`);
|
|
228
|
+
console.log(` ${comment.body}`);
|
|
229
|
+
if (isInline && comment.suggestion) {
|
|
230
|
+
console.log(` ${colors.green}Suggestion: ${comment.suggestion}${colors.reset}`);
|
|
231
|
+
}
|
|
232
|
+
console.log('');
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// ─── Main Command ───────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Analyze a GitHub PR with team guidelines
|
|
239
|
+
* @param {Array<string>} args - Command arguments [pr-url, ...flags]
|
|
240
|
+
*/
|
|
241
|
+
export async function runAnalyzePr(args) {
|
|
242
|
+
if (!checkGitRepo()) {
|
|
243
|
+
error('You are not in a Git repository.');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Parse arguments
|
|
248
|
+
const prUrl = args.find((a) => !a.startsWith('--'));
|
|
249
|
+
const cliPreset = args.includes('--preset') ? args[args.indexOf('--preset') + 1] : null;
|
|
250
|
+
const cliModel = args.includes('--model') ? args[args.indexOf('--model') + 1] : null;
|
|
251
|
+
const dryRun = args.includes('--dry-run');
|
|
252
|
+
|
|
253
|
+
if (!prUrl) {
|
|
254
|
+
error(
|
|
255
|
+
'Usage: claude-hooks analyze-pr <pr-url> [--preset <name>] [--model <model>] [--dry-run]'
|
|
256
|
+
);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Step 1: Parse URL
|
|
261
|
+
const parsed = parseGitHubPRUrl(prUrl);
|
|
262
|
+
if (!parsed) {
|
|
263
|
+
error(`Invalid GitHub PR URL: ${prUrl}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const { owner, repo, number } = parsed;
|
|
268
|
+
info(`Analyzing PR #${number} in ${owner}/${repo}...`);
|
|
269
|
+
|
|
270
|
+
// Load config
|
|
271
|
+
const config = await getConfig();
|
|
272
|
+
if (config.system?.debug) {
|
|
273
|
+
logger.setDebugMode(true);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Step 2: Validate GitHub token
|
|
277
|
+
try {
|
|
278
|
+
getGitHubToken();
|
|
279
|
+
} catch {
|
|
280
|
+
error('GitHub token not configured. Run: claude-hooks setup-github');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const startTime = Date.now();
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
// Step 3: Fetch PR details + files
|
|
288
|
+
const [prData, prFiles] = await Promise.all([
|
|
289
|
+
fetchPullRequest(owner, repo, number),
|
|
290
|
+
fetchPullRequestFiles(owner, repo, number)
|
|
291
|
+
]);
|
|
292
|
+
|
|
293
|
+
info(`PR: "${prData.title}" (${prFiles.length} files changed)`);
|
|
294
|
+
|
|
295
|
+
// Step 4: Extract Linear ticket
|
|
296
|
+
let ticketContext = 'No ticket context available.';
|
|
297
|
+
let ticketLabels = [];
|
|
298
|
+
|
|
299
|
+
const linearId = extractLinearTicketFromTitle(prData.title);
|
|
300
|
+
if (linearId) {
|
|
301
|
+
info(`Linear ticket detected: ${linearId}`);
|
|
302
|
+
const ticket = await fetchTicket(linearId);
|
|
303
|
+
|
|
304
|
+
if (ticket) {
|
|
305
|
+
ticketContext = [
|
|
306
|
+
`**Ticket:** ${ticket.identifier} - ${ticket.title}`,
|
|
307
|
+
`**State:** ${ticket.state}`,
|
|
308
|
+
ticket.description ? `**Description:** ${ticket.description}` : '',
|
|
309
|
+
ticket.labels.length > 0 ? `**Labels:** ${ticket.labels.join(', ')}` : ''
|
|
310
|
+
]
|
|
311
|
+
.filter(Boolean)
|
|
312
|
+
.join('\n');
|
|
313
|
+
ticketLabels = ticket.labels;
|
|
314
|
+
} else {
|
|
315
|
+
warning(`Could not fetch Linear ticket ${linearId}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Step 5: Determine preset
|
|
320
|
+
const presetName = await resolvePreset({
|
|
321
|
+
cliPreset,
|
|
322
|
+
prLabels: prData.labels,
|
|
323
|
+
ticketLabels,
|
|
324
|
+
prFiles
|
|
325
|
+
});
|
|
326
|
+
info(`Using preset: ${presetName}`);
|
|
327
|
+
|
|
328
|
+
// Step 6: Load preset guidelines
|
|
329
|
+
let presetGuidelines = 'Standard code review guidelines apply.';
|
|
330
|
+
try {
|
|
331
|
+
const { templates } = await loadPreset(presetName);
|
|
332
|
+
const fs = await import('fs/promises');
|
|
333
|
+
|
|
334
|
+
const guidelinesParts = [];
|
|
335
|
+
if (templates.guidelines) {
|
|
336
|
+
const content = await fs.readFile(templates.guidelines, 'utf8');
|
|
337
|
+
guidelinesParts.push(content);
|
|
338
|
+
}
|
|
339
|
+
if (templates.prompt) {
|
|
340
|
+
const content = await fs.readFile(templates.prompt, 'utf8');
|
|
341
|
+
guidelinesParts.push(content);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (guidelinesParts.length > 0) {
|
|
345
|
+
presetGuidelines = guidelinesParts.join('\n\n');
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
warning(`Could not load preset "${presetName}" guidelines, using defaults`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Step 7: Build category strings from config
|
|
352
|
+
const inlineCategories = config.prAnalysis?.inlineCategories || [
|
|
353
|
+
'bug',
|
|
354
|
+
'security',
|
|
355
|
+
'performance',
|
|
356
|
+
'hotspot'
|
|
357
|
+
];
|
|
358
|
+
const generalCategories = config.prAnalysis?.generalCategories || [
|
|
359
|
+
'ticket-alignment',
|
|
360
|
+
'scope',
|
|
361
|
+
'style',
|
|
362
|
+
'good-practice',
|
|
363
|
+
'extensibility',
|
|
364
|
+
'observability',
|
|
365
|
+
'documentation',
|
|
366
|
+
'testing'
|
|
367
|
+
];
|
|
368
|
+
const allCategories = [...inlineCategories, ...generalCategories];
|
|
369
|
+
|
|
370
|
+
const inlineCategoriesStr = inlineCategories.map((c) => `- \`${c}\``).join('\n');
|
|
371
|
+
const generalCategoriesStr = generalCategories.map((c) => `- \`${c}\``).join('\n');
|
|
372
|
+
|
|
373
|
+
// Build file list and diff for template
|
|
374
|
+
const filesStr = prFiles
|
|
375
|
+
.map((f) => `- ${f.filename} (+${f.additions}/-${f.deletions}) [${f.status}]`)
|
|
376
|
+
.join('\n');
|
|
377
|
+
|
|
378
|
+
const diffStr = prFiles
|
|
379
|
+
.filter((f) => f.patch)
|
|
380
|
+
.map((f) => `--- ${f.filename} ---\n${f.patch}`)
|
|
381
|
+
.join('\n\n');
|
|
382
|
+
|
|
383
|
+
// Step 8: Build prompt
|
|
384
|
+
const model = cliModel || config.prAnalysis?.model || 'sonnet';
|
|
385
|
+
const timeout = config.prAnalysis?.timeout || 300000;
|
|
386
|
+
|
|
387
|
+
const prompt = await loadPrompt('ANALYZE_PR.md', {
|
|
388
|
+
PR_TITLE: prData.title,
|
|
389
|
+
PR_BODY: prData.body || 'No description provided.',
|
|
390
|
+
PR_FILES: filesStr,
|
|
391
|
+
PR_DIFF: diffStr || 'No diff available.',
|
|
392
|
+
TICKET_CONTEXT: ticketContext,
|
|
393
|
+
PRESET_GUIDELINES: presetGuidelines,
|
|
394
|
+
INLINE_CATEGORIES: inlineCategoriesStr,
|
|
395
|
+
GENERAL_CATEGORIES: generalCategoriesStr
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// Step 9: Call Claude
|
|
399
|
+
info('Running analysis...');
|
|
400
|
+
const response = await executeClaudeWithRetry(prompt, {
|
|
401
|
+
timeout,
|
|
402
|
+
model,
|
|
403
|
+
telemetryContext: { hook: 'analyze-pr', fileCount: prFiles.length }
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
logger.debug('analyze-pr - runAnalyzePr', 'Claude response received', {
|
|
407
|
+
responseLength: response.length,
|
|
408
|
+
responsePreview: response.substring(0, 300)
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
let result;
|
|
412
|
+
try {
|
|
413
|
+
result = extractJSON(response);
|
|
414
|
+
} catch (jsonErr) {
|
|
415
|
+
// Surface the actual response so users can diagnose prompt issues
|
|
416
|
+
const preview = response.substring(0, 500);
|
|
417
|
+
error(
|
|
418
|
+
`Failed to parse Claude response as JSON. Claude returned:\n${preview}${response.length > 500 ? '\n...(truncated)' : ''}`
|
|
419
|
+
);
|
|
420
|
+
throw jsonErr;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Step 10: Normalize categories
|
|
424
|
+
const inlineComments = (result.inlineComments || []).map((c) => ({
|
|
425
|
+
...c,
|
|
426
|
+
category: normalizeCategory(c.category, allCategories, CATEGORY_ALIASES)
|
|
427
|
+
}));
|
|
428
|
+
const generalComments = (result.generalComments || []).map((c) => ({
|
|
429
|
+
...c,
|
|
430
|
+
category: normalizeCategory(c.category, allCategories, CATEGORY_ALIASES)
|
|
431
|
+
}));
|
|
432
|
+
|
|
433
|
+
// Step 11: Display results
|
|
434
|
+
const elapsed = Date.now() - startTime;
|
|
435
|
+
const seconds = (elapsed / 1000).toFixed(1);
|
|
436
|
+
|
|
437
|
+
console.log('');
|
|
438
|
+
console.log('================================================================');
|
|
439
|
+
console.log(' PR ANALYSIS RESULTS ');
|
|
440
|
+
console.log('================================================================');
|
|
441
|
+
console.log('');
|
|
442
|
+
console.log(`${colors.blue}Verdict:${colors.reset} ${result.verdict || 'comment'}`);
|
|
443
|
+
console.log(`${colors.blue}Summary:${colors.reset} ${result.summary || 'No summary.'}`);
|
|
444
|
+
console.log(`${colors.blue}Preset:${colors.reset} ${presetName}`);
|
|
445
|
+
console.log(`${colors.blue}Model:${colors.reset} ${model}`);
|
|
446
|
+
console.log(
|
|
447
|
+
`${colors.blue}Issues:${colors.reset} ${inlineComments.length} inline, ${generalComments.length} general`
|
|
448
|
+
);
|
|
449
|
+
console.log('');
|
|
450
|
+
|
|
451
|
+
if (inlineComments.length > 0) {
|
|
452
|
+
console.log(`${colors.green}--- Inline Comments ---${colors.reset}`);
|
|
453
|
+
console.log('');
|
|
454
|
+
inlineComments.forEach((c, i) => displayComment(c, i + 1, true));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (generalComments.length > 0) {
|
|
458
|
+
console.log(`${colors.green}--- General Comments ---${colors.reset}`);
|
|
459
|
+
console.log('');
|
|
460
|
+
generalComments.forEach((c, i) => displayComment(c, i + 1, false));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (inlineComments.length === 0 && generalComments.length === 0) {
|
|
464
|
+
console.log(`${colors.green}No issues found. This PR looks good!${colors.reset}`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
console.log(`${colors.blue}Analysis completed in ${seconds}s${colors.reset}`);
|
|
468
|
+
console.log('');
|
|
469
|
+
|
|
470
|
+
// Step 12: Interactive comment workflow
|
|
471
|
+
if (dryRun) {
|
|
472
|
+
info('Dry run mode: skipping comment posting.');
|
|
473
|
+
recordStats({
|
|
474
|
+
prUrl,
|
|
475
|
+
number,
|
|
476
|
+
owner,
|
|
477
|
+
repo,
|
|
478
|
+
presetName,
|
|
479
|
+
result,
|
|
480
|
+
inlineComments,
|
|
481
|
+
generalComments,
|
|
482
|
+
commentsPosted: 0,
|
|
483
|
+
commentsSkipped: inlineComments.length + generalComments.length
|
|
484
|
+
});
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (inlineComments.length === 0 && generalComments.length === 0) {
|
|
489
|
+
info('No comments to post.');
|
|
490
|
+
recordStats({
|
|
491
|
+
prUrl,
|
|
492
|
+
number,
|
|
493
|
+
owner,
|
|
494
|
+
repo,
|
|
495
|
+
presetName,
|
|
496
|
+
result,
|
|
497
|
+
inlineComments,
|
|
498
|
+
generalComments,
|
|
499
|
+
commentsPosted: 0,
|
|
500
|
+
commentsSkipped: 0
|
|
501
|
+
});
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Ask user to confirm posting
|
|
506
|
+
const postChoice = await promptMenu(
|
|
507
|
+
'What would you like to do with the review?',
|
|
508
|
+
[
|
|
509
|
+
{ key: 'a', label: 'Post all comments as a review' },
|
|
510
|
+
{ key: 's', label: 'Select which comments to post' },
|
|
511
|
+
{ key: 'n', label: 'Skip posting (analysis only)' }
|
|
512
|
+
],
|
|
513
|
+
'a'
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
let confirmedInline = [];
|
|
517
|
+
let confirmedGeneral = [];
|
|
518
|
+
|
|
519
|
+
if (postChoice === 'n') {
|
|
520
|
+
info('Skipping comment posting.');
|
|
521
|
+
recordStats({
|
|
522
|
+
prUrl,
|
|
523
|
+
number,
|
|
524
|
+
owner,
|
|
525
|
+
repo,
|
|
526
|
+
presetName,
|
|
527
|
+
result,
|
|
528
|
+
inlineComments,
|
|
529
|
+
generalComments,
|
|
530
|
+
commentsPosted: 0,
|
|
531
|
+
commentsSkipped: inlineComments.length + generalComments.length
|
|
532
|
+
});
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (postChoice === 'a') {
|
|
537
|
+
confirmedInline = inlineComments;
|
|
538
|
+
confirmedGeneral = generalComments;
|
|
539
|
+
} else if (postChoice === 's') {
|
|
540
|
+
// Select inline comments
|
|
541
|
+
for (const comment of inlineComments) {
|
|
542
|
+
displayComment(comment, inlineComments.indexOf(comment) + 1, true);
|
|
543
|
+
const keep = await promptConfirmation('Post this inline comment?', true);
|
|
544
|
+
if (keep) confirmedInline.push(comment);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Select general comments
|
|
548
|
+
for (const comment of generalComments) {
|
|
549
|
+
displayComment(comment, generalComments.indexOf(comment) + 1, false);
|
|
550
|
+
const keep = await promptConfirmation('Include this general comment?', true);
|
|
551
|
+
if (keep) confirmedGeneral.push(comment);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Step 13: Post review
|
|
556
|
+
if (confirmedInline.length === 0 && confirmedGeneral.length === 0) {
|
|
557
|
+
info('No comments selected for posting.');
|
|
558
|
+
recordStats({
|
|
559
|
+
prUrl,
|
|
560
|
+
number,
|
|
561
|
+
owner,
|
|
562
|
+
repo,
|
|
563
|
+
presetName,
|
|
564
|
+
result,
|
|
565
|
+
inlineComments,
|
|
566
|
+
generalComments,
|
|
567
|
+
commentsPosted: 0,
|
|
568
|
+
commentsSkipped: inlineComments.length + generalComments.length
|
|
569
|
+
});
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Build review body from general comments
|
|
574
|
+
let reviewBody = `## PR Analysis (${presetName} preset)\n\n`;
|
|
575
|
+
reviewBody += `**Verdict:** ${result.verdict || 'comment'}\n\n`;
|
|
576
|
+
|
|
577
|
+
if (result.summary) {
|
|
578
|
+
reviewBody += `${result.summary}\n\n`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (confirmedGeneral.length > 0) {
|
|
582
|
+
reviewBody += '### Review Observations\n\n';
|
|
583
|
+
for (const comment of confirmedGeneral) {
|
|
584
|
+
const icon = SEVERITY_ICONS[comment.severity] || '\u{26AA}';
|
|
585
|
+
reviewBody += `${icon} **[${comment.category}]** ${comment.body}\n\n`;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Determine review event
|
|
590
|
+
const event = result.verdict === 'approve' ? 'APPROVE' : 'COMMENT';
|
|
591
|
+
|
|
592
|
+
// Format inline comments for API
|
|
593
|
+
const apiComments = confirmedInline.map((c) => {
|
|
594
|
+
let body = `**[${c.category}]** ${c.body}`;
|
|
595
|
+
if (c.suggestion) {
|
|
596
|
+
body += `\n\n**Suggestion:**\n\`\`\`suggestion\n${c.suggestion}\n\`\`\``;
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
path: c.path,
|
|
600
|
+
line: c.line,
|
|
601
|
+
side: c.side || 'RIGHT',
|
|
602
|
+
body
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
info('Posting review...');
|
|
607
|
+
await createPullRequestReview(owner, repo, number, reviewBody, apiComments, event);
|
|
608
|
+
|
|
609
|
+
const totalPosted = confirmedInline.length + confirmedGeneral.length;
|
|
610
|
+
const totalSkipped = inlineComments.length + generalComments.length - totalPosted;
|
|
611
|
+
|
|
612
|
+
console.log(
|
|
613
|
+
`${colors.green}Review posted: ${confirmedInline.length} inline comments, ${confirmedGeneral.length} general observations${colors.reset}`
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
// Step 14: Record statistics
|
|
617
|
+
recordStats({
|
|
618
|
+
prUrl,
|
|
619
|
+
number,
|
|
620
|
+
owner,
|
|
621
|
+
repo,
|
|
622
|
+
presetName,
|
|
623
|
+
result,
|
|
624
|
+
inlineComments,
|
|
625
|
+
generalComments,
|
|
626
|
+
commentsPosted: totalPosted,
|
|
627
|
+
commentsSkipped: totalSkipped
|
|
628
|
+
});
|
|
629
|
+
} catch (e) {
|
|
630
|
+
if (e.name === 'GitHubAPIError') {
|
|
631
|
+
error(`GitHub API error: ${e.message}`);
|
|
632
|
+
} else {
|
|
633
|
+
error(`Error analyzing PR: ${e.message}`);
|
|
634
|
+
logger.debug('analyze-pr - runAnalyzePr', 'Full error details', {
|
|
635
|
+
name: e.name,
|
|
636
|
+
message: e.message,
|
|
637
|
+
context: e.context ? JSON.stringify(e.context).substring(0, 500) : undefined,
|
|
638
|
+
stack: e.stack
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Record analysis statistics
|
|
646
|
+
* @private
|
|
647
|
+
*/
|
|
648
|
+
function recordStats({
|
|
649
|
+
prUrl,
|
|
650
|
+
number,
|
|
651
|
+
owner,
|
|
652
|
+
repo,
|
|
653
|
+
presetName,
|
|
654
|
+
result,
|
|
655
|
+
inlineComments,
|
|
656
|
+
generalComments,
|
|
657
|
+
commentsPosted,
|
|
658
|
+
commentsSkipped
|
|
659
|
+
}) {
|
|
660
|
+
const issuesByCategory = {};
|
|
661
|
+
[...inlineComments, ...generalComments].forEach((c) => {
|
|
662
|
+
issuesByCategory[c.category] = (issuesByCategory[c.category] || 0) + 1;
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
recordPRAnalysis({
|
|
666
|
+
url: prUrl,
|
|
667
|
+
number,
|
|
668
|
+
repo: `${owner}/${repo}`,
|
|
669
|
+
preset: presetName,
|
|
670
|
+
verdict: result.verdict || 'comment',
|
|
671
|
+
totalIssues: inlineComments.length + generalComments.length,
|
|
672
|
+
issuesByCategory,
|
|
673
|
+
inlineCount: inlineComments.length,
|
|
674
|
+
generalCount: generalComments.length,
|
|
675
|
+
commentsPosted,
|
|
676
|
+
commentsSkipped
|
|
677
|
+
});
|
|
678
|
+
}
|