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.
@@ -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
+ }