codecritique 2.1.2 → 2.2.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.
- package/README.md +7 -0
- package/package.json +1 -1
- package/src/index.js +28 -16
- package/src/index.test.js +86 -0
- package/src/llm.js +49 -1
- package/src/llm.test.js +82 -0
- package/src/rag-analyzer.js +3 -1
- package/src/rag-analyzer.test.js +34 -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
|
|
@@ -545,6 +548,10 @@ VERBOSE=true
|
|
|
545
548
|
|
|
546
549
|
Set `CODECRITIQUE_SKIP_DOTENV=1` to disable `.env` loading, as the shell wrapper does after forwarding its supported variables.
|
|
547
550
|
|
|
551
|
+
### Trust and Prompt Injection
|
|
552
|
+
|
|
553
|
+
Repository files, diffs, custom documents, and PR comments are untrusted data included in LLM prompts. A malicious repository can attempt to influence generated review text despite prompt boundaries and truncation. Treat CodeCritique output as advisory, review findings before acting on them, and never grant generated suggestions automatic execution privileges.
|
|
554
|
+
|
|
548
555
|
## Output Formats
|
|
549
556
|
|
|
550
557
|
CodeCritique supports three output formats:
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -42,6 +42,7 @@ async function cleanupEmbeddingsSystemIfInitialized() {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
45
|
+
const parseIntegerOption = (value) => Number.parseInt(value, 10);
|
|
45
46
|
|
|
46
47
|
// Configure command-line interface
|
|
47
48
|
program.name('codecritique').description('CLI tool for AI-powered code review using the RAG approach').version(packageJson.version);
|
|
@@ -64,11 +65,12 @@ program
|
|
|
64
65
|
.option('--verbose', 'Show verbose output')
|
|
65
66
|
.option('--model <model>', 'LLM model to use (e.g., claude-sonnet-4-6)')
|
|
66
67
|
.option('--temperature <number>', 'LLM temperature', parseFloat, 0.2)
|
|
67
|
-
.option('--max-tokens <number>', 'LLM max tokens',
|
|
68
|
+
.option('--max-tokens <number>', 'LLM max tokens', parseIntegerOption, 8192)
|
|
68
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')
|
|
69
71
|
.option('--similarity-threshold <number>', 'Threshold for finding similar code examples', parseFloat, 0.6)
|
|
70
|
-
.option('--max-examples <number>', 'Max similar code examples to use',
|
|
71
|
-
.option('--concurrency <number>', 'Concurrency for processing multiple files',
|
|
72
|
+
.option('--max-examples <number>', 'Max similar code examples to use', parseIntegerOption, 5)
|
|
73
|
+
.option('--concurrency <number>', 'Concurrency for processing multiple files', parseIntegerOption, 3)
|
|
72
74
|
.option(
|
|
73
75
|
'--doc <specs...>',
|
|
74
76
|
'A document to provide custom instructions to the LLM (e.g., "Engineering Guidelines:./docs/guidelines.md"). Can be specified multiple times.'
|
|
@@ -142,11 +144,11 @@ program
|
|
|
142
144
|
.option('-t, --token <token>', 'GitHub API token (or set GITHUB_TOKEN env var)')
|
|
143
145
|
.option('--since <date>', 'Only analyze PRs since this date (ISO format)')
|
|
144
146
|
.option('--until <date>', 'Only analyze PRs until this date (ISO format)')
|
|
145
|
-
.option('--limit <number>', 'Limit number of PRs to analyze',
|
|
147
|
+
.option('--limit <number>', 'Limit number of PRs to analyze', parseIntegerOption)
|
|
146
148
|
.option('--resume', 'Resume interrupted analysis')
|
|
147
149
|
.option('--clear', 'Clear existing data before analysis')
|
|
148
|
-
.option('--concurrency <number>', 'Number of concurrent requests',
|
|
149
|
-
.option('--batch-size <number>', 'Batch size for processing',
|
|
150
|
+
.option('--concurrency <number>', 'Number of concurrent requests', parseIntegerOption, 2)
|
|
151
|
+
.option('--batch-size <number>', 'Batch size for processing', parseIntegerOption, 50)
|
|
150
152
|
.option('--verbose', 'Show verbose output')
|
|
151
153
|
.action(analyzePRHistory);
|
|
152
154
|
|
|
@@ -349,6 +351,7 @@ async function runCodeReview(options) {
|
|
|
349
351
|
temperature: options.temperature,
|
|
350
352
|
maxTokens: options.maxTokens,
|
|
351
353
|
cacheTtl: options.cacheTtl,
|
|
354
|
+
batch: options.batch,
|
|
352
355
|
similarityThreshold: options.similarityThreshold,
|
|
353
356
|
maxExamples: options.maxExamples,
|
|
354
357
|
concurrency: options.concurrency,
|
|
@@ -1092,11 +1095,12 @@ async function emitEmptyReviewOutputIfNeeded(options, aggregateResult = {}) {
|
|
|
1092
1095
|
}
|
|
1093
1096
|
|
|
1094
1097
|
async function emitErrorReviewOutputIfNeeded(options, error, aggregateResult = {}) {
|
|
1095
|
-
if (!shouldEmitReviewOutput(options)) {
|
|
1098
|
+
if (!shouldEmitReviewOutput(options) && !aggregateResult.results?.length) {
|
|
1096
1099
|
return false;
|
|
1097
1100
|
}
|
|
1098
1101
|
|
|
1099
|
-
|
|
1102
|
+
const reviewResults = aggregateResult.results?.length ? aggregateResult.results : [{ filePath: 'review', success: false, error }];
|
|
1103
|
+
await outputReviewResults(reviewResults, options, {
|
|
1100
1104
|
...aggregateResult,
|
|
1101
1105
|
success: false,
|
|
1102
1106
|
error,
|
|
@@ -1104,14 +1108,6 @@ async function emitErrorReviewOutputIfNeeded(options, error, aggregateResult = {
|
|
|
1104
1108
|
return true;
|
|
1105
1109
|
}
|
|
1106
1110
|
|
|
1107
|
-
function createReviewErrorResult(error) {
|
|
1108
|
-
return {
|
|
1109
|
-
filePath: 'review',
|
|
1110
|
-
success: false,
|
|
1111
|
-
error,
|
|
1112
|
-
};
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
1111
|
/**
|
|
1116
1112
|
* Output results in JSON format
|
|
1117
1113
|
*
|
|
@@ -1139,6 +1135,8 @@ async function outputJson(reviewResults, options, aggregateResult = {}) {
|
|
|
1139
1135
|
}, 0),
|
|
1140
1136
|
skippedFiles: reviewResults.filter((r) => r.skipped).length,
|
|
1141
1137
|
errorFiles: reviewResults.filter((r) => !r.success).length,
|
|
1138
|
+
incomplete: aggregateResult.success === false,
|
|
1139
|
+
error: aggregateResult.success === false ? aggregateResult.error : undefined,
|
|
1142
1140
|
},
|
|
1143
1141
|
details: reviewResults.map((r) => {
|
|
1144
1142
|
if (!r.success) {
|
|
@@ -1151,6 +1149,8 @@ async function outputJson(reviewResults, options, aggregateResult = {}) {
|
|
|
1151
1149
|
return {
|
|
1152
1150
|
filePath: r.filePath,
|
|
1153
1151
|
success: true,
|
|
1152
|
+
partial: r.partial || undefined,
|
|
1153
|
+
error: r.partial ? r.error : undefined,
|
|
1154
1154
|
language: r.language,
|
|
1155
1155
|
review: r.results, // Contains summary and actionable issues (with optional codeSuggestion)
|
|
1156
1156
|
// Optionally include similar examples if needed
|
|
@@ -1207,6 +1207,9 @@ async function outputMarkdown(reviewResults, options, aggregateResult = {}) {
|
|
|
1207
1207
|
if (errorFiles > 0) {
|
|
1208
1208
|
lines.push(`- **Errors:** ${errorFiles}`);
|
|
1209
1209
|
}
|
|
1210
|
+
if (aggregateResult.success === false) {
|
|
1211
|
+
lines.push(`- **Review Incomplete:** ${aggregateResult.error || 'Some review work failed.'}`);
|
|
1212
|
+
}
|
|
1210
1213
|
|
|
1211
1214
|
appendPRLevelFindingsMarkdown(lines, prLevelFindings);
|
|
1212
1215
|
|
|
@@ -1218,6 +1221,9 @@ async function outputMarkdown(reviewResults, options, aggregateResult = {}) {
|
|
|
1218
1221
|
lines.push(`**Error:** ${fileResult.error}`, '');
|
|
1219
1222
|
return;
|
|
1220
1223
|
}
|
|
1224
|
+
if (fileResult.partial) {
|
|
1225
|
+
lines.push(`**Partial review:** ${fileResult.error || 'Some review segments failed.'}`, '');
|
|
1226
|
+
}
|
|
1221
1227
|
if (fileResult.skipped) {
|
|
1222
1228
|
lines.push('*Skipped (based on exclusion patterns or file type).*', '');
|
|
1223
1229
|
return;
|
|
@@ -1295,6 +1301,9 @@ function outputText(reviewResults, cliOptions, aggregateResult = {}) {
|
|
|
1295
1301
|
if (errorFiles > 0) {
|
|
1296
1302
|
console.log(`Errors: ${chalk.red(errorFiles)}`);
|
|
1297
1303
|
}
|
|
1304
|
+
if (aggregateResult.success === false) {
|
|
1305
|
+
console.warn(chalk.yellow(`Review Incomplete: ${aggregateResult.error || 'Some review work failed.'}`));
|
|
1306
|
+
}
|
|
1298
1307
|
console.log(chalk.bold.blue('================================================'));
|
|
1299
1308
|
|
|
1300
1309
|
outputPRLevelFindingsText(prLevelFindings);
|
|
@@ -1306,6 +1315,9 @@ function outputText(reviewResults, cliOptions, aggregateResult = {}) {
|
|
|
1306
1315
|
console.log(chalk.bold.red('================================================'));
|
|
1307
1316
|
return;
|
|
1308
1317
|
}
|
|
1318
|
+
if (fileResult.partial) {
|
|
1319
|
+
console.warn(chalk.yellow(`\nPartial review for ${fileResult.filePath}: ${fileResult.error || 'Some review segments failed.'}`));
|
|
1320
|
+
}
|
|
1309
1321
|
if (fileResult.skipped) {
|
|
1310
1322
|
verboseLog(cliOptions, chalk.yellow(`\nSkipped: ${fileResult.filePath}`));
|
|
1311
1323
|
return;
|
|
@@ -0,0 +1,86 @@
|
|
|
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';
|
|
5
|
+
import { expect, it, onTestFinished, vi } from 'vitest';
|
|
6
|
+
|
|
7
|
+
const { originalArgv, originalListeners, reviewFile } = vi.hoisted(() => {
|
|
8
|
+
const originalArgv = process.argv;
|
|
9
|
+
const originalListeners = Object.fromEntries(['SIGINT', 'SIGTERM', 'exit'].map((event) => [event, process.listeners(event)]));
|
|
10
|
+
process.argv = ['node', 'index.js', 'analyze', '--file', 'src/index.js', '--max-examples', '12'];
|
|
11
|
+
return {
|
|
12
|
+
originalArgv,
|
|
13
|
+
originalListeners,
|
|
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
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
vi.unmock('chalk');
|
|
31
|
+
vi.mock('./rag-review.js', () => ({
|
|
32
|
+
reviewFile,
|
|
33
|
+
reviewFiles: vi.fn(),
|
|
34
|
+
reviewPullRequest: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
import './index.js';
|
|
38
|
+
|
|
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 () => {
|
|
42
|
+
process.argv = originalArgv;
|
|
43
|
+
for (const [event, listeners] of Object.entries(originalListeners)) {
|
|
44
|
+
process
|
|
45
|
+
.listeners(event)
|
|
46
|
+
.filter((listener) => !listeners.includes(listener))
|
|
47
|
+
.forEach((listener) => process.removeListener(event, listener));
|
|
48
|
+
}
|
|
49
|
+
await rm(directory, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
await vi.waitFor(() => expect(reviewFile).toHaveBeenCalled());
|
|
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');
|
|
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...'));
|
|
@@ -2343,7 +2344,8 @@ async function gatherUnifiedContextForPR(prFiles, options = {}) {
|
|
|
2343
2344
|
// Aggregate and deduplicate results
|
|
2344
2345
|
for (const context of allContexts) {
|
|
2345
2346
|
(context.finalCodeExamples || []).forEach((example) => {
|
|
2346
|
-
const
|
|
2347
|
+
const normalizedContent = example.content?.trim();
|
|
2348
|
+
const key = normalizedContent ? `content:${normalizedContent}` : example.path ? `path:${example.path}` : null;
|
|
2347
2349
|
if (
|
|
2348
2350
|
key &&
|
|
2349
2351
|
(!allProcessedContext.codeExamples.has(key) || example.similarity > allProcessedContext.codeExamples.get(key).similarity)
|
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');
|
|
@@ -1136,6 +1144,32 @@ describe('rag-analyzer', () => {
|
|
|
1136
1144
|
expect(context.codeExamples[0].similarity).toBeGreaterThanOrEqual(0.7);
|
|
1137
1145
|
}
|
|
1138
1146
|
});
|
|
1147
|
+
|
|
1148
|
+
it('should deduplicate identical code content from different paths', async () => {
|
|
1149
|
+
mockEmbeddingsSystem.findSimilarCode
|
|
1150
|
+
.mockResolvedValueOnce([{ path: '/first.js', content: 'shared implementation', similarity: 0.7 }])
|
|
1151
|
+
.mockResolvedValueOnce([{ path: '/second.js', content: ' shared implementation ', similarity: 0.9 }]);
|
|
1152
|
+
const context = await gatherUnifiedContextForPR([
|
|
1153
|
+
{ filePath: '/src/file1.js', content: 'code1' },
|
|
1154
|
+
{ filePath: '/src/file2.js', content: 'code2' },
|
|
1155
|
+
]);
|
|
1156
|
+
|
|
1157
|
+
expect(context.codeExamples).toEqual([
|
|
1158
|
+
expect.objectContaining({ path: '/second.js', content: ' shared implementation ', similarity: 0.9 }),
|
|
1159
|
+
]);
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it('should keep content keys separate from path fallbacks', async () => {
|
|
1163
|
+
mockEmbeddingsSystem.findSimilarCode
|
|
1164
|
+
.mockResolvedValueOnce([{ path: '/first.js', content: '', similarity: 0.9 }])
|
|
1165
|
+
.mockResolvedValueOnce([{ path: '/second.js', content: ' /first.js ', similarity: 0.8 }]);
|
|
1166
|
+
const context = await gatherUnifiedContextForPR([
|
|
1167
|
+
{ filePath: '/src/file1.js', content: 'code1' },
|
|
1168
|
+
{ filePath: '/src/file2.js', content: 'code2' },
|
|
1169
|
+
]);
|
|
1170
|
+
|
|
1171
|
+
expect(context.codeExamples.map((example) => example.path)).toEqual(['/first.js', '/second.js']);
|
|
1172
|
+
});
|
|
1139
1173
|
});
|
|
1140
1174
|
|
|
1141
1175
|
// ==========================================================================
|
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({
|