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 +3 -0
- package/package.json +2 -2
- package/src/index.js +21 -10
- package/src/index.test.js +54 -3
- package/src/llm.js +49 -1
- package/src/llm.test.js +82 -0
- package/src/rag-analyzer.js +1 -0
- package/src/rag-analyzer.test.js +8 -0
- package/src/rag-review.js +6 -1
- package/src/rag-review.test.js +30 -0
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
|
+
"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.
|
|
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
|
-
|
|
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({
|
|
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
|
|
24
|
-
|
|
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 =
|
|
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', () => {
|
package/src/rag-analyzer.js
CHANGED
|
@@ -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...'));
|
package/src/rag-analyzer.test.js
CHANGED
|
@@ -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:
|
|
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: {
|
package/src/rag-review.test.js
CHANGED
|
@@ -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({
|