claude-git-hooks 2.35.2 → 2.43.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.
@@ -24,15 +24,30 @@ import {
24
24
  getGitHubToken
25
25
  } from '../utils/github-api.js';
26
26
  import { executeClaudeWithRetry, extractJSON } from '../utils/claude-client.js';
27
+ import { flush as langfuseFlush } from '../utils/langfuse-tracer.js';
27
28
  import { loadPrompt } from '../utils/prompt-builder.js';
28
29
  import { loadPreset, listPresets } from '../utils/preset-loader.js';
29
30
  import { extractLinearTicketFromTitle, fetchTicket } from '../utils/linear-connector.js';
30
31
  import { recordPRAnalysis } from '../utils/pr-statistics.js';
31
32
  import { promptConfirmation, promptMenu } from '../utils/interactive-ui.js';
32
- import { colors, error, info, warning, checkGitRepo } from './helpers.js';
33
+ import { CostTracker } from '../utils/cost-tracker.js';
34
+ import { colors, error, fatal, info, warning, checkGitRepo } from './helpers.js';
33
35
  import logger from '../utils/logger.js';
34
36
  import path from 'path';
35
37
 
38
+ // ─── JSON Error Helper ────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Emit error JSON to stdout and set exit code
42
+ * @param {string} message - Error message
43
+ * @param {number} exitCode - Process exit code (default: 1)
44
+ * @private
45
+ */
46
+ function _emitErrorJSON(message, exitCode = 1) {
47
+ process.stdout.write(`${JSON.stringify({ status: 'error', error: message })}\n`);
48
+ process.exitCode = exitCode;
49
+ }
50
+
36
51
  // ─── Category Normalization ─────────────────────────────────────────────────
37
52
 
38
53
  /**
@@ -239,20 +254,35 @@ const displayComment = (comment, index, isInline) => {
239
254
  * @param {Array<string>} args - Command arguments [pr-url, ...flags]
240
255
  */
241
256
  export async function runAnalyzePr(args) {
242
- if (!checkGitRepo()) {
243
- error('You are not in a Git repository.');
244
- return;
245
- }
246
-
247
257
  // Parse arguments
248
258
  const prUrl = args.find((a) => !a.startsWith('--'));
249
259
  const cliPreset = args.includes('--preset') ? args[args.indexOf('--preset') + 1] : null;
250
260
  const cliModel = args.includes('--model') ? args[args.indexOf('--model') + 1] : null;
251
261
  const dryRun = args.includes('--dry-run');
262
+ const headless = args.includes('--headless');
263
+ const fmtIdx = args.indexOf('--format');
264
+ const format = fmtIdx >= 0 ? args[fmtIdx + 1] : null;
265
+ const isJSON = format === 'json';
266
+
267
+ // Validation: --format json requires --headless
268
+ if (isJSON && !headless) {
269
+ _emitErrorJSON('--format json requires --headless', 2);
270
+ return;
271
+ }
272
+
273
+ // Activate JSON mode before any output so info/warning route to stderr
274
+ if (isJSON) logger.setJSONMode(true);
275
+
276
+ if (!checkGitRepo()) {
277
+ if (isJSON) { _emitErrorJSON('Not a git repository'); return; }
278
+ error('You are not in a Git repository.');
279
+ return;
280
+ }
252
281
 
253
282
  if (!prUrl) {
283
+ if (isJSON) { _emitErrorJSON('No PR URL provided'); return; }
254
284
  error(
255
- 'Usage: claude-hooks analyze-pr <pr-url> [--preset <name>] [--model <model>] [--dry-run]'
285
+ 'Usage: claude-hooks analyze-pr <pr-url> [--preset <name>] [--model <model>] [--dry-run] [--headless] [--format json]'
256
286
  );
257
287
  return;
258
288
  }
@@ -260,6 +290,7 @@ export async function runAnalyzePr(args) {
260
290
  // Step 1: Parse URL
261
291
  const parsed = parseGitHubPRUrl(prUrl);
262
292
  if (!parsed) {
293
+ if (isJSON) { _emitErrorJSON(`Invalid GitHub PR URL: ${prUrl}`); return; }
263
294
  error(`Invalid GitHub PR URL: ${prUrl}`);
264
295
  return;
265
296
  }
@@ -277,6 +308,7 @@ export async function runAnalyzePr(args) {
277
308
  try {
278
309
  getGitHubToken();
279
310
  } catch {
311
+ if (isJSON) { _emitErrorJSON('GitHub token not configured. Run: claude-hooks setup-github'); return; }
280
312
  error('GitHub token not configured. Run: claude-hooks setup-github');
281
313
  return;
282
314
  }
@@ -397,12 +429,20 @@ export async function runAnalyzePr(args) {
397
429
 
398
430
  // Step 9: Call Claude
399
431
  info('Running analysis...');
432
+ const costTracker = headless ? new CostTracker() : null;
400
433
  const response = await executeClaudeWithRetry(prompt, {
401
434
  timeout,
402
435
  model,
436
+ headless,
437
+ costTracker,
403
438
  telemetryContext: { hook: 'analyze-pr', fileCount: prFiles.length }
404
439
  });
405
440
 
441
+ // Flush Langfuse traces after headless SDK call (no-op when disabled)
442
+ if (headless) {
443
+ await langfuseFlush().catch(() => {});
444
+ }
445
+
406
446
  logger.debug('analyze-pr - runAnalyzePr', 'Claude response received', {
407
447
  responseLength: response.length,
408
448
  responsePreview: response.substring(0, 300)
@@ -430,147 +470,155 @@ export async function runAnalyzePr(args) {
430
470
  category: normalizeCategory(c.category, allCategories, CATEGORY_ALIASES)
431
471
  }));
432
472
 
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('');
473
+ // Step 11: Display results (skip in JSON mode — output is the JSON document)
474
+ if (!isJSON) {
475
+ const elapsed = Date.now() - startTime;
476
+ const seconds = (elapsed / 1000).toFixed(1);
450
477
 
451
- if (inlineComments.length > 0) {
452
- console.log(`${colors.green}--- Inline Comments ---${colors.reset}`);
453
478
  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}`);
479
+ console.log('================================================================');
480
+ console.log(' PR ANALYSIS RESULTS ');
481
+ console.log('================================================================');
482
+ console.log('');
483
+ console.log(`${colors.blue}Verdict:${colors.reset} ${result.verdict || 'comment'}`);
484
+ console.log(`${colors.blue}Summary:${colors.reset} ${result.summary || 'No summary.'}`);
485
+ console.log(`${colors.blue}Preset:${colors.reset} ${presetName}`);
486
+ console.log(`${colors.blue}Model:${colors.reset} ${model}`);
487
+ console.log(
488
+ `${colors.blue}Issues:${colors.reset} ${inlineComments.length} inline, ${generalComments.length} general`
489
+ );
459
490
  console.log('');
460
- generalComments.forEach((c, i) => displayComment(c, i + 1, false));
461
- }
462
491
 
463
- if (inlineComments.length === 0 && generalComments.length === 0) {
464
- console.log(`${colors.green}No issues found. This PR looks good!${colors.reset}`);
465
- }
492
+ if (inlineComments.length > 0) {
493
+ console.log(`${colors.green}--- Inline Comments ---${colors.reset}`);
494
+ console.log('');
495
+ inlineComments.forEach((c, i) => displayComment(c, i + 1, true));
496
+ }
466
497
 
467
- console.log(`${colors.blue}Analysis completed in ${seconds}s${colors.reset}`);
468
- console.log('');
498
+ if (generalComments.length > 0) {
499
+ console.log(`${colors.green}--- General Comments ---${colors.reset}`);
500
+ console.log('');
501
+ generalComments.forEach((c, i) => displayComment(c, i + 1, false));
502
+ }
469
503
 
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
- }
504
+ if (inlineComments.length === 0 && generalComments.length === 0) {
505
+ console.log(`${colors.green}No issues found. This PR looks good!${colors.reset}`);
506
+ }
487
507
 
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;
508
+ console.log(`${colors.blue}Analysis completed in ${seconds}s${colors.reset}`);
509
+ console.log('');
503
510
  }
504
511
 
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 = [];
512
+ // Step 12: Determine confirmed comments (interactive vs headless)
513
+ let confirmedInline;
514
+ let confirmedGeneral;
515
+ let posted = false;
518
516
 
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') {
517
+ if (headless) {
518
+ // Headless: post all comments (no interactive selection)
537
519
  confirmedInline = inlineComments;
538
520
  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);
521
+ } else {
522
+ // Interactive: existing prompt-driven workflow
523
+ if (dryRun) {
524
+ info('Dry run mode: skipping comment posting.');
525
+ recordStats({
526
+ prUrl,
527
+ number,
528
+ owner,
529
+ repo,
530
+ presetName,
531
+ result,
532
+ inlineComments,
533
+ generalComments,
534
+ commentsPosted: 0,
535
+ commentsSkipped: inlineComments.length + generalComments.length
536
+ });
537
+ return;
545
538
  }
546
539
 
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);
540
+ if (inlineComments.length === 0 && generalComments.length === 0) {
541
+ info('No comments to post.');
542
+ recordStats({
543
+ prUrl,
544
+ number,
545
+ owner,
546
+ repo,
547
+ presetName,
548
+ result,
549
+ inlineComments,
550
+ generalComments,
551
+ commentsPosted: 0,
552
+ commentsSkipped: 0
553
+ });
554
+ return;
552
555
  }
553
- }
554
556
 
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;
557
+ const postChoice = await promptMenu(
558
+ 'What would you like to do with the review?',
559
+ [
560
+ { key: 'a', label: 'Post all comments as a review' },
561
+ { key: 's', label: 'Select which comments to post' },
562
+ { key: 'n', label: 'Skip posting (analysis only)' }
563
+ ],
564
+ 'a'
565
+ );
566
+
567
+ confirmedInline = [];
568
+ confirmedGeneral = [];
569
+
570
+ if (postChoice === 'n') {
571
+ info('Skipping comment posting.');
572
+ recordStats({
573
+ prUrl,
574
+ number,
575
+ owner,
576
+ repo,
577
+ presetName,
578
+ result,
579
+ inlineComments,
580
+ generalComments,
581
+ commentsPosted: 0,
582
+ commentsSkipped: inlineComments.length + generalComments.length
583
+ });
584
+ return;
585
+ }
586
+
587
+ if (postChoice === 'a') {
588
+ confirmedInline = inlineComments;
589
+ confirmedGeneral = generalComments;
590
+ } else if (postChoice === 's') {
591
+ for (const comment of inlineComments) {
592
+ displayComment(comment, inlineComments.indexOf(comment) + 1, true);
593
+ const keep = await promptConfirmation('Post this inline comment?', true);
594
+ if (keep) confirmedInline.push(comment);
595
+ }
596
+ for (const comment of generalComments) {
597
+ displayComment(comment, generalComments.indexOf(comment) + 1, false);
598
+ const keep = await promptConfirmation('Include this general comment?', true);
599
+ if (keep) confirmedGeneral.push(comment);
600
+ }
601
+ }
602
+
603
+ if (confirmedInline.length === 0 && confirmedGeneral.length === 0) {
604
+ info('No comments selected for posting.');
605
+ recordStats({
606
+ prUrl,
607
+ number,
608
+ owner,
609
+ repo,
610
+ presetName,
611
+ result,
612
+ inlineComments,
613
+ generalComments,
614
+ commentsPosted: 0,
615
+ commentsSkipped: inlineComments.length + generalComments.length
616
+ });
617
+ return;
618
+ }
571
619
  }
572
620
 
573
- // Build review body from general comments
621
+ // Step 13: Build review body from general comments
574
622
  let reviewBody = `## PR Analysis (${presetName} preset)\n\n`;
575
623
  reviewBody += `**Verdict:** ${result.verdict || 'comment'}\n\n`;
576
624
 
@@ -603,17 +651,85 @@ export async function runAnalyzePr(args) {
603
651
  };
604
652
  });
605
653
 
606
- info('Posting review...');
607
- await createPullRequestReview(owner, repo, number, reviewBody, apiComments, event);
654
+ // Post review (unless dry-run)
655
+ if (!dryRun && (confirmedInline.length > 0 || confirmedGeneral.length > 0)) {
656
+ info('Posting review...');
657
+ await createPullRequestReview(owner, repo, number, reviewBody, apiComments, event);
658
+ posted = true;
659
+ }
608
660
 
609
- const totalPosted = confirmedInline.length + confirmedGeneral.length;
661
+ const totalPosted = posted ? confirmedInline.length + confirmedGeneral.length : 0;
610
662
  const totalSkipped = inlineComments.length + generalComments.length - totalPosted;
611
663
 
612
- console.log(
613
- `${colors.green}Review posted: ${confirmedInline.length} inline comments, ${confirmedGeneral.length} general observations${colors.reset}`
614
- );
664
+ // Step 14: JSON output
665
+ if (isJSON) {
666
+ const severityCounts = { critical: 0, major: 0, minor: 0, info: 0 };
667
+ const categoryCounts = {};
668
+ [...inlineComments, ...generalComments].forEach((c) => {
669
+ if (severityCounts[c.severity] !== undefined) severityCounts[c.severity]++;
670
+ categoryCounts[c.category] = (categoryCounts[c.category] || 0) + 1;
671
+ });
672
+
673
+ const verdict = result.verdict || 'comment';
674
+ const isFail = severityCounts.critical > 0 || verdict === 'request_changes';
675
+
676
+ const payload = {
677
+ status: isFail ? 'fail' : 'pass',
678
+ verdict,
679
+ summary: result.summary || '',
680
+ preset: presetName,
681
+ model,
682
+ durationMs: Date.now() - startTime,
683
+ issueCount: inlineComments.length + generalComments.length,
684
+ severityCounts,
685
+ categoryCounts,
686
+ inlineComments: inlineComments.map((c) => ({
687
+ path: c.path,
688
+ line: c.line,
689
+ side: c.side || 'RIGHT',
690
+ severity: c.severity,
691
+ category: c.category,
692
+ body: c.body,
693
+ ...(c.suggestion ? { suggestion: c.suggestion } : {})
694
+ })),
695
+ generalComments: generalComments.map((c) => ({
696
+ severity: c.severity,
697
+ category: c.category,
698
+ body: c.body
699
+ })),
700
+ reviewBody,
701
+ posted,
702
+ costs: costTracker ? costTracker.toJSON() : null
703
+ };
704
+
705
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
706
+ process.exitCode = isFail ? 1 : 0;
707
+
708
+ recordStats({
709
+ prUrl,
710
+ number,
711
+ owner,
712
+ repo,
713
+ presetName,
714
+ result,
715
+ inlineComments,
716
+ generalComments,
717
+ commentsPosted: totalPosted,
718
+ commentsSkipped: totalSkipped
719
+ });
720
+ return;
721
+ }
722
+
723
+ // Non-JSON display output
724
+ if (posted) {
725
+ console.log(
726
+ `${colors.green}Review posted: ${confirmedInline.length} inline comments, ${confirmedGeneral.length} general observations${colors.reset}`
727
+ );
728
+ } else if (dryRun) {
729
+ info('Dry run mode: skipping comment posting.');
730
+ }
615
731
 
616
- // Step 14: Record statistics
732
+ // Record statistics
617
733
  recordStats({
618
734
  prUrl,
619
735
  number,
@@ -627,16 +743,25 @@ export async function runAnalyzePr(args) {
627
743
  commentsSkipped: totalSkipped
628
744
  });
629
745
  } catch (e) {
746
+ if (isJSON) {
747
+ process.stdout.write(
748
+ `${JSON.stringify({ status: 'error', error: e.message, errorName: e.name })}\n`
749
+ );
750
+ process.exitCode = 1;
751
+ return;
752
+ }
753
+
754
+ logger.debug('analyze-pr - runAnalyzePr', 'Full error details', {
755
+ name: e.name,
756
+ message: e.message,
757
+ context: e.context ? JSON.stringify(e.context).substring(0, 500) : undefined,
758
+ stack: e.stack
759
+ });
760
+
630
761
  if (e.name === 'GitHubAPIError') {
631
- error(`GitHub API error: ${e.message}`);
762
+ fatal(`GitHub API error: ${e.message}`);
632
763
  } 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
- });
764
+ fatal(`Error analyzing PR: ${e.message}`);
640
765
  }
641
766
  }
642
767
  }