claude-git-hooks 2.20.0 → 2.21.0

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