codecritique 2.1.3 → 2.2.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/README.md CHANGED
@@ -418,9 +418,12 @@ jobs:
418
418
  | `model` | LLM model to use (e.g., `claude-sonnet-4-6`) | No | Auto-selected |
419
419
  | `max-tokens` | Maximum tokens for LLM response | No | Auto-calculated |
420
420
  | `cache-ttl` | Cache TTL for LLM prompts: "5m" (default, no extra cost) or "1h" (extended, extra cost for cache writes) | No | `5m` |
421
+ | `batch` | Use Anthropic Message Batches for lower-cost, asynchronous reviews | No | `false` |
421
422
  | `concurrency` | Concurrency for processing multiple files | No | `3` |
422
423
  | `custom-docs` | Custom documents (format: `"title:path,title:path"`) | No | `''` |
423
424
 
425
+ Batch mode trades latency for Anthropic's Message Batches API discount. Processing may take up to 24 hours, and batch requests are not eligible for Zero Data Retention. GitHub-hosted jobs can run for at most six hours; the action polls in-process and cannot resume a batch after the job ends. If holistic analysis falls back to per-file reviews, each concurrency wave can incur a separate batch wait and further increase runtime.
426
+
424
427
  > **Note**: The action uses sensible defaults for all review parameters. It always:
425
428
  >
426
429
  > - Uses JSON output format for parsing results
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecritique",
3
- "version": "2.1.3",
3
+ "version": "2.2.1",
4
4
  "description": "AI-powered code review tool for any programming language",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -53,7 +53,7 @@
53
53
  "dependencies": {
54
54
  "@anthropic-ai/sdk": "0.71.0",
55
55
  "@huggingface/transformers": "3.8.0",
56
- "@lancedb/lancedb": "0.30.0",
56
+ "@lancedb/lancedb": "0.33.0",
57
57
  "@octokit/rest": "22.0.1",
58
58
  "apache-arrow": "18.1.0",
59
59
  "chalk": "5.6.2",
package/src/index.js CHANGED
@@ -67,6 +67,7 @@ program
67
67
  .option('--temperature <number>', 'LLM temperature', parseFloat, 0.2)
68
68
  .option('--max-tokens <number>', 'LLM max tokens', parseIntegerOption, 8192)
69
69
  .option('--cache-ttl <ttl>', 'Cache TTL for LLM prompts: "5m" (default, no extra cost) or "1h" (extended, extra cost for writes)', '5m')
70
+ .option('--batch', 'Use the asynchronous Anthropic Message Batches API for lower-cost reviews')
70
71
  .option('--similarity-threshold <number>', 'Threshold for finding similar code examples', parseFloat, 0.6)
71
72
  .option('--max-examples <number>', 'Max similar code examples to use', parseIntegerOption, 5)
72
73
  .option('--concurrency <number>', 'Concurrency for processing multiple files', parseIntegerOption, 3)
@@ -350,6 +351,7 @@ async function runCodeReview(options) {
350
351
  temperature: options.temperature,
351
352
  maxTokens: options.maxTokens,
352
353
  cacheTtl: options.cacheTtl,
354
+ batch: options.batch,
353
355
  similarityThreshold: options.similarityThreshold,
354
356
  maxExamples: options.maxExamples,
355
357
  concurrency: options.concurrency,
@@ -1093,11 +1095,12 @@ async function emitEmptyReviewOutputIfNeeded(options, aggregateResult = {}) {
1093
1095
  }
1094
1096
 
1095
1097
  async function emitErrorReviewOutputIfNeeded(options, error, aggregateResult = {}) {
1096
- if (!shouldEmitReviewOutput(options)) {
1098
+ if (!shouldEmitReviewOutput(options) && !aggregateResult.results?.length) {
1097
1099
  return false;
1098
1100
  }
1099
1101
 
1100
- await outputReviewResults([createReviewErrorResult(error)], options, {
1102
+ const reviewResults = aggregateResult.results?.length ? aggregateResult.results : [{ filePath: 'review', success: false, error }];
1103
+ await outputReviewResults(reviewResults, options, {
1101
1104
  ...aggregateResult,
1102
1105
  success: false,
1103
1106
  error,
@@ -1105,14 +1108,6 @@ async function emitErrorReviewOutputIfNeeded(options, error, aggregateResult = {
1105
1108
  return true;
1106
1109
  }
1107
1110
 
1108
- function createReviewErrorResult(error) {
1109
- return {
1110
- filePath: 'review',
1111
- success: false,
1112
- error,
1113
- };
1114
- }
1115
-
1116
1111
  /**
1117
1112
  * Output results in JSON format
1118
1113
  *
@@ -1140,6 +1135,8 @@ async function outputJson(reviewResults, options, aggregateResult = {}) {
1140
1135
  }, 0),
1141
1136
  skippedFiles: reviewResults.filter((r) => r.skipped).length,
1142
1137
  errorFiles: reviewResults.filter((r) => !r.success).length,
1138
+ incomplete: aggregateResult.success === false,
1139
+ error: aggregateResult.success === false ? aggregateResult.error : undefined,
1143
1140
  },
1144
1141
  details: reviewResults.map((r) => {
1145
1142
  if (!r.success) {
@@ -1152,6 +1149,8 @@ async function outputJson(reviewResults, options, aggregateResult = {}) {
1152
1149
  return {
1153
1150
  filePath: r.filePath,
1154
1151
  success: true,
1152
+ partial: r.partial || undefined,
1153
+ error: r.partial ? r.error : undefined,
1155
1154
  language: r.language,
1156
1155
  review: r.results, // Contains summary and actionable issues (with optional codeSuggestion)
1157
1156
  // Optionally include similar examples if needed
@@ -1208,6 +1207,9 @@ async function outputMarkdown(reviewResults, options, aggregateResult = {}) {
1208
1207
  if (errorFiles > 0) {
1209
1208
  lines.push(`- **Errors:** ${errorFiles}`);
1210
1209
  }
1210
+ if (aggregateResult.success === false) {
1211
+ lines.push(`- **Review Incomplete:** ${aggregateResult.error || 'Some review work failed.'}`);
1212
+ }
1211
1213
 
1212
1214
  appendPRLevelFindingsMarkdown(lines, prLevelFindings);
1213
1215
 
@@ -1219,6 +1221,9 @@ async function outputMarkdown(reviewResults, options, aggregateResult = {}) {
1219
1221
  lines.push(`**Error:** ${fileResult.error}`, '');
1220
1222
  return;
1221
1223
  }
1224
+ if (fileResult.partial) {
1225
+ lines.push(`**Partial review:** ${fileResult.error || 'Some review segments failed.'}`, '');
1226
+ }
1222
1227
  if (fileResult.skipped) {
1223
1228
  lines.push('*Skipped (based on exclusion patterns or file type).*', '');
1224
1229
  return;
@@ -1296,6 +1301,9 @@ function outputText(reviewResults, cliOptions, aggregateResult = {}) {
1296
1301
  if (errorFiles > 0) {
1297
1302
  console.log(`Errors: ${chalk.red(errorFiles)}`);
1298
1303
  }
1304
+ if (aggregateResult.success === false) {
1305
+ console.warn(chalk.yellow(`Review Incomplete: ${aggregateResult.error || 'Some review work failed.'}`));
1306
+ }
1299
1307
  console.log(chalk.bold.blue('================================================'));
1300
1308
 
1301
1309
  outputPRLevelFindingsText(prLevelFindings);
@@ -1307,6 +1315,9 @@ function outputText(reviewResults, cliOptions, aggregateResult = {}) {
1307
1315
  console.log(chalk.bold.red('================================================'));
1308
1316
  return;
1309
1317
  }
1318
+ if (fileResult.partial) {
1319
+ console.warn(chalk.yellow(`\nPartial review for ${fileResult.filePath}: ${fileResult.error || 'Some review segments failed.'}`));
1320
+ }
1310
1321
  if (fileResult.skipped) {
1311
1322
  verboseLog(cliOptions, chalk.yellow(`\nSkipped: ${fileResult.filePath}`));
1312
1323
  return;
package/src/index.test.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { program } from 'commander';
1
5
  import { expect, it, onTestFinished, vi } from 'vitest';
2
6
 
3
7
  const { originalArgv, originalListeners, reviewFile } = vi.hoisted(() => {
@@ -7,7 +11,19 @@ const { originalArgv, originalListeners, reviewFile } = vi.hoisted(() => {
7
11
  return {
8
12
  originalArgv,
9
13
  originalListeners,
10
- reviewFile: vi.fn().mockResolvedValue({ success: true, results: [] }),
14
+ reviewFile: vi.fn().mockResolvedValue({
15
+ success: false,
16
+ error: 'Review incomplete',
17
+ results: [
18
+ {
19
+ filePath: 'src/index.js',
20
+ success: true,
21
+ partial: true,
22
+ error: 'Batch expired',
23
+ results: { issues: [{ severity: 'high', description: 'Visible partial finding', lineNumbers: [1] }] },
24
+ },
25
+ ],
26
+ }),
11
27
  };
12
28
  });
13
29
 
@@ -20,8 +36,9 @@ vi.mock('./rag-review.js', () => ({
20
36
 
21
37
  import './index.js';
22
38
 
23
- it('parses integer options independently of Commander defaults', async () => {
24
- onTestFinished(() => {
39
+ it('parses integer options and renders incomplete results in every output format', async () => {
40
+ const directory = await mkdtemp(join(tmpdir(), 'codecritique-output-'));
41
+ onTestFinished(async () => {
25
42
  process.argv = originalArgv;
26
43
  for (const [event, listeners] of Object.entries(originalListeners)) {
27
44
  process
@@ -29,7 +46,41 @@ it('parses integer options independently of Commander defaults', async () => {
29
46
  .filter((listener) => !listeners.includes(listener))
30
47
  .forEach((listener) => process.removeListener(event, listener));
31
48
  }
49
+ await rm(directory, { recursive: true, force: true });
32
50
  });
33
51
  await vi.waitFor(() => expect(reviewFile).toHaveBeenCalled());
34
52
  expect(reviewFile.mock.calls[0][1].maxExamples).toBe(12);
53
+ await vi.waitFor(() => expect(console.log).toHaveBeenCalledWith(' Visible partial finding'));
54
+ expect(console.warn.mock.calls.some(([message]) => String(message).includes('Partial review for src/index.js'))).toBe(true);
55
+ expect(console.warn.mock.calls.some(([message]) => String(message).includes('Review incomplete'))).toBe(true);
56
+
57
+ const jsonPath = join(directory, 'review.json');
58
+ await program.parseAsync(['node', 'index.js', 'analyze', '--file', 'src/index.js', '--output', 'json', '--output-file', jsonPath]);
59
+ const jsonOutput = JSON.parse(await readFile(jsonPath, 'utf8'));
60
+ expect(jsonOutput.summary).toEqual(expect.objectContaining({ errorFiles: 0, incomplete: true, error: 'Review incomplete' }));
61
+ expect(jsonOutput.details).toEqual([
62
+ expect.objectContaining({
63
+ success: true,
64
+ partial: true,
65
+ error: 'Batch expired',
66
+ review: expect.objectContaining({ issues: [expect.objectContaining({ description: 'Visible partial finding' })] }),
67
+ }),
68
+ ]);
69
+
70
+ const markdownPath = join(directory, 'review.md');
71
+ await program.parseAsync([
72
+ 'node',
73
+ 'index.js',
74
+ 'analyze',
75
+ '--file',
76
+ 'src/index.js',
77
+ '--output',
78
+ 'markdown',
79
+ '--output-file',
80
+ markdownPath,
81
+ ]);
82
+ const markdownOutput = await readFile(markdownPath, 'utf8');
83
+ expect(markdownOutput).toContain('**Review Incomplete:** Review incomplete');
84
+ expect(markdownOutput).toContain('**Partial review:** Batch expired');
85
+ expect(markdownOutput).toContain('Visible partial finding');
35
86
  });
package/src/llm.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * requests, reducing input token costs by 75%.
13
13
  */
14
14
 
15
+ import { setTimeout as delay } from 'node:timers/promises';
15
16
  import { Anthropic } from '@anthropic-ai/sdk';
16
17
  import chalk from 'chalk';
17
18
  import dotenv from 'dotenv';
@@ -46,6 +47,9 @@ const DEFAULT_MODEL = 'claude-sonnet-4-6';
46
47
  // Maximum tokens for response
47
48
  const MAX_TOKENS = 4096;
48
49
 
50
+ const DEFAULT_BATCH_POLL_INTERVAL_MS = 10000;
51
+ const MAX_BATCH_POLL_FAILURES = 3;
52
+
49
53
  const MAX_CACHE_CONTROL_BLOCKS = 4;
50
54
 
51
55
  function normalizeSystemBlock(block, cacheControl) {
@@ -84,6 +88,47 @@ function buildSystemContent(system, cachedSystemBlocks, cacheControl) {
84
88
  return systemBlocks;
85
89
  }
86
90
 
91
+ async function createBatchedMessage(client, requestParams, options) {
92
+ const customId = 'codecritique-review';
93
+ let batch = await client.messages.batches.create({
94
+ requests: [{ custom_id: customId, params: requestParams }],
95
+ });
96
+
97
+ verboseLog(options, chalk.cyan(`Submitted Claude message batch ${batch.id}; waiting for completion...`));
98
+ let consecutivePollFailures = 0;
99
+
100
+ while (batch.processing_status !== 'ended') {
101
+ await delay(DEFAULT_BATCH_POLL_INTERVAL_MS);
102
+ try {
103
+ batch = await client.messages.batches.retrieve(batch.id);
104
+ consecutivePollFailures = 0;
105
+ }
106
+ catch (error) {
107
+ consecutivePollFailures++;
108
+ if (consecutivePollFailures >= MAX_BATCH_POLL_FAILURES) {
109
+ throw new Error(`Unable to poll Claude message batch ${batch.id} after ${MAX_BATCH_POLL_FAILURES} attempts: ${error.message}`);
110
+ }
111
+ console.warn(chalk.yellow(`Unable to poll Claude message batch ${batch.id}; retrying: ${error.message}`));
112
+ }
113
+ }
114
+
115
+ const results = await client.messages.batches.results(batch.id);
116
+ for await (const response of results) {
117
+ if (response.custom_id !== customId) {
118
+ continue;
119
+ }
120
+
121
+ if (response.result.type === 'succeeded') {
122
+ return response.result.message;
123
+ }
124
+
125
+ const detail = response.result.type === 'errored' ? `: ${response.result.error.error.message}` : '';
126
+ throw new Error(`Claude batch request ${response.result.type}${detail}`);
127
+ }
128
+
129
+ throw new Error('Claude batch response did not contain the review request');
130
+ }
131
+
87
132
  /**
88
133
  * Send a prompt to Claude and get a structured JSON response using tool calling.
89
134
  * Uses prompt caching for system prompts to reduce token costs.
@@ -94,6 +139,7 @@ function buildSystemContent(system, cachedSystemBlocks, cacheControl) {
94
139
  * @param {Array<string|Object>} options.cachedSystemBlocks - Additional stable system blocks to cache when possible
95
140
  * @param {Object} options.jsonSchema - JSON schema for structured output
96
141
  * @param {string} options.cacheTtl - Cache TTL: '5m' (default, no extra cost) or '1h' (extended, extra cost for writes)
142
+ * @param {boolean} [options.batch=false] - Use the asynchronous Message Batches API
97
143
  * @returns {Promise<Object>} The response from Claude with structured data
98
144
  */
99
145
  async function sendPromptToClaude(prompt, options = {}) {
@@ -145,7 +191,9 @@ async function sendPromptToClaude(prompt, options = {}) {
145
191
  requestParams.tool_choice = { type: 'tool', name: 'return_json' };
146
192
  }
147
193
 
148
- const response = await client.messages.create(requestParams);
194
+ const response = options.batch
195
+ ? await createBatchedMessage(client, requestParams, options)
196
+ : await client.messages.create(requestParams);
149
197
 
150
198
  // Log response structure for debugging
151
199
  verboseLog(options, chalk.gray(` Response stop_reason: ${response.stop_reason}`));
package/src/llm.test.js CHANGED
@@ -2,15 +2,31 @@ import { sendPromptToClaude } from './llm.js';
2
2
 
3
3
  // Create hoisted mock for the Anthropic SDK
4
4
  const mockMessagesCreate = vi.hoisted(() => vi.fn());
5
+ const mockBatchesCreate = vi.hoisted(() => vi.fn());
6
+ const mockBatchesRetrieve = vi.hoisted(() => vi.fn());
7
+ const mockBatchesResults = vi.hoisted(() => vi.fn());
8
+
9
+ vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn() }));
5
10
 
6
11
  vi.mock('@anthropic-ai/sdk', () => ({
7
12
  Anthropic: class MockAnthropic {
8
13
  messages = {
9
14
  create: mockMessagesCreate,
15
+ batches: {
16
+ create: mockBatchesCreate,
17
+ retrieve: mockBatchesRetrieve,
18
+ results: mockBatchesResults,
19
+ },
10
20
  };
11
21
  },
12
22
  }));
13
23
 
24
+ function batchResults(...responses) {
25
+ return (async function* iterateResults() {
26
+ yield* responses;
27
+ })();
28
+ }
29
+
14
30
  describe('sendPromptToClaude', () => {
15
31
  beforeEach(() => {
16
32
  mockConsoleSelective('log', 'error');
@@ -85,6 +101,72 @@ describe('sendPromptToClaude', () => {
85
101
  })
86
102
  );
87
103
  });
104
+
105
+ it('should retry polling and unwrap a successful batch request', async () => {
106
+ const message = {
107
+ content: [{ type: 'text', text: 'Batched response' }],
108
+ model: 'claude-sonnet-4-6',
109
+ usage: { input_tokens: 100, output_tokens: 50 },
110
+ };
111
+ mockBatchesCreate.mockResolvedValue({ id: 'msgbatch_123', processing_status: 'in_progress' });
112
+ mockBatchesRetrieve
113
+ .mockRejectedValueOnce(new Error('Temporary network error'))
114
+ .mockRejectedValueOnce(new Error('Temporary network error'))
115
+ .mockResolvedValueOnce({ id: 'msgbatch_123', processing_status: 'in_progress' })
116
+ .mockRejectedValueOnce(new Error('Temporary network error'))
117
+ .mockRejectedValueOnce(new Error('Temporary network error'))
118
+ .mockResolvedValueOnce({ id: 'msgbatch_123', processing_status: 'ended' });
119
+ mockBatchesResults.mockResolvedValue(batchResults({ custom_id: 'codecritique-review', result: { type: 'succeeded', message } }));
120
+
121
+ const result = await sendPromptToClaude('Review this code', { batch: true });
122
+
123
+ expect(result.content).toBe('Batched response');
124
+ expect(mockMessagesCreate).not.toHaveBeenCalled();
125
+ expect(mockBatchesCreate).toHaveBeenCalledWith({
126
+ requests: [
127
+ {
128
+ custom_id: 'codecritique-review',
129
+ params: expect.objectContaining({
130
+ model: 'claude-sonnet-4-6',
131
+ messages: [{ role: 'user', content: 'Review this code' }],
132
+ }),
133
+ },
134
+ ],
135
+ });
136
+ expect(mockBatchesRetrieve).toHaveBeenCalledTimes(6);
137
+ expect(mockBatchesRetrieve).toHaveBeenCalledWith('msgbatch_123');
138
+ expect(mockBatchesResults).toHaveBeenCalledWith('msgbatch_123');
139
+ });
140
+
141
+ it('should stop polling after repeated retrieval failures', async () => {
142
+ mockBatchesCreate.mockResolvedValue({ id: 'msgbatch_123', processing_status: 'in_progress' });
143
+ mockBatchesRetrieve.mockRejectedValue(new Error('Unauthorized'));
144
+
145
+ await expect(sendPromptToClaude('Review this code', { batch: true })).rejects.toThrow(
146
+ 'Unable to poll Claude message batch msgbatch_123 after 3 attempts: Unauthorized'
147
+ );
148
+ expect(mockBatchesRetrieve).toHaveBeenCalledTimes(3);
149
+ });
150
+
151
+ it.each([
152
+ [{ type: 'errored', error: { error: { message: 'Request overloaded' } } }, 'Claude batch request errored: Request overloaded'],
153
+ [{ type: 'canceled' }, 'Claude batch request canceled'],
154
+ [{ type: 'expired' }, 'Claude batch request expired'],
155
+ ])('should report an unsuccessful batch result', async (batchResult, expectedMessage) => {
156
+ mockBatchesCreate.mockResolvedValue({ id: 'msgbatch_123', processing_status: 'ended' });
157
+ mockBatchesResults.mockResolvedValue(batchResults({ custom_id: 'codecritique-review', result: batchResult }));
158
+
159
+ await expect(sendPromptToClaude('Review this code', { batch: true })).rejects.toThrow(expectedMessage);
160
+ });
161
+
162
+ it('should reject a batch response without the review result', async () => {
163
+ mockBatchesCreate.mockResolvedValue({ id: 'msgbatch_123', processing_status: 'ended' });
164
+ mockBatchesResults.mockResolvedValue(batchResults());
165
+
166
+ await expect(sendPromptToClaude('Review this code', { batch: true })).rejects.toThrow(
167
+ 'Claude batch response did not contain the review request'
168
+ );
169
+ });
88
170
  });
89
171
 
90
172
  describe('structured JSON response with tool calling', () => {
@@ -782,6 +782,7 @@ async function callLLMForAnalysis(context, options = {}) {
782
782
  isJsonMode: true, // Standardize on using JSON mode if available
783
783
  verbose: options.verbose || false, // Pass verbose flag for cache statistics
784
784
  cacheTtl: options.cacheTtl || '5m', // Pass cache TTL option (default: 5m, no extra cost)
785
+ batch: options.batch || false,
785
786
  });
786
787
 
787
788
  verboseLog(options, chalk.blue('Received LLM response, attempting to parse...'));
@@ -196,6 +196,14 @@ describe('rag-analyzer', () => {
196
196
  expect(result.error).toContain('LLM unavailable');
197
197
  });
198
198
 
199
+ it('should forward batch transport options to the LLM', async () => {
200
+ setupSuccessfulLLMResponse();
201
+
202
+ await runAnalysis('/test/file.js', { batch: true });
203
+
204
+ expect(llm.sendPromptToClaude).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ batch: true }));
205
+ });
206
+
199
207
  it('should initialize embeddings system', async () => {
200
208
  setupSuccessfulLLMResponse();
201
209
  await runAnalysis('/test/file.js');
package/src/rag-review.js CHANGED
@@ -399,6 +399,9 @@ async function reviewPullRequestWithCrossFileContext(filesToReview, options = {}
399
399
 
400
400
  // Create a synthetic "file" path for holistic analysis
401
401
  const holisticResult = await runAnalysis('PR_HOLISTIC_REVIEW', holisticOptions);
402
+ if (!holisticResult?.success) {
403
+ throw new Error(holisticResult?.error || 'Holistic PR analysis failed');
404
+ }
402
405
 
403
406
  // Convert holistic result to individual file results format for compatibility
404
407
  const results = prFiles.map((file) => {
@@ -525,8 +528,10 @@ async function reviewPullRequestWithCrossFileContext(filesToReview, options = {}
525
528
  }
526
529
 
527
530
  // Return fallback results
531
+ const failedReviewCount = results.filter((result) => !result?.success).length;
528
532
  return {
529
- success: true,
533
+ success: failedReviewCount === 0,
534
+ error: failedReviewCount > 0 ? `${failedReviewCount} file review(s) failed after holistic review fallback` : undefined,
530
535
  results: results,
531
536
  prContext: prContext,
532
537
  sharedContextStats: {
@@ -436,6 +436,36 @@ describe('rag-review', () => {
436
436
  expect(result.success).toBe(true);
437
437
  });
438
438
 
439
+ it('should retain successful fallback results when another file fails', async () => {
440
+ shouldProcessFile.mockReturnValue(true);
441
+ getFileContentFromGit.mockReturnValue('const x = 1;');
442
+ getChangedLinesInfo.mockReturnValue({
443
+ hasChanges: true,
444
+ addedLines: [1],
445
+ removedLines: [],
446
+ fullDiff: '+ code',
447
+ });
448
+ shouldChunkPR.mockReturnValue({ shouldChunk: false, estimatedTokens: 1000 });
449
+ runAnalysis
450
+ .mockResolvedValueOnce({ success: false, error: 'Claude batch request expired' })
451
+ .mockResolvedValueOnce({
452
+ success: true,
453
+ filePath: '/src/a.js',
454
+ results: { issues: [{ description: 'Known issue' }] },
455
+ })
456
+ .mockResolvedValueOnce({ success: false, filePath: '/src/b.js', error: 'Claude batch request expired' });
457
+
458
+ const result = await reviewPullRequest(['/src/a.js', '/src/b.js'], { batch: true });
459
+
460
+ expect(result.success).toBe(false);
461
+ expect(result.error).toBe('1 file review(s) failed after holistic review fallback');
462
+ expect(result.results).toEqual([
463
+ expect.objectContaining({ success: true, filePath: '/src/a.js' }),
464
+ expect.objectContaining({ success: false, filePath: '/src/b.js', error: 'Claude batch request expired' }),
465
+ ]);
466
+ expect(runAnalysis).toHaveBeenLastCalledWith('/src/b.js', expect.objectContaining({ batch: true, isPRReview: true, diffOnly: true }));
467
+ });
468
+
439
469
  it('should handle multiple files with different statuses', async () => {
440
470
  runAnalysis
441
471
  .mockResolvedValueOnce({