openairev 0.3.11 → 0.3.13
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/package.json +1 -1
- package/src/agents/claude-code.js +1 -1
- package/src/cli/review.js +12 -9
- package/src/mcp/mcp-server.js +8 -16
- package/src/orchestrator/orchestrator.js +9 -20
- package/src/review/review-runner.js +9 -25
- package/src/tools/git-tools.js +32 -0
- package/src/review/input-stager.js +0 -137
- package/src/review/input-stager.test.js +0 -53
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ export class ClaudeCodeAdapter {
|
|
|
25
25
|
stream = false,
|
|
26
26
|
signal,
|
|
27
27
|
} = {}) {
|
|
28
|
-
const args = ['-p', prompt];
|
|
28
|
+
const args = ['-p', prompt, '--max-budget-usd', '5'];
|
|
29
29
|
|
|
30
30
|
if (stream) {
|
|
31
31
|
args.push('--output-format', 'stream-json', '--verbose', '--include-partial-messages');
|
package/src/cli/review.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { readFileSync } from 'fs';
|
|
3
2
|
import { loadConfig, getReviewer, getMaxIterations } from '../config/config-loader.js';
|
|
4
|
-
import {
|
|
3
|
+
import { hasDiff, buildDiffCmd } from '../tools/git-tools.js';
|
|
5
4
|
import { runReview } from '../review/review-runner.js';
|
|
6
5
|
import { runWorkflow } from '../orchestrator/orchestrator.js';
|
|
7
6
|
import { createSession, saveSession } from '../session/session-manager.js';
|
|
@@ -37,13 +36,17 @@ export async function reviewCommand(options) {
|
|
|
37
36
|
console.log(` Mode: ${chalk.cyan('implement → review loop')}`);
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
let
|
|
39
|
+
let diffCmd = '';
|
|
40
|
+
let hasChanges = false;
|
|
41
41
|
if (options.file) {
|
|
42
42
|
console.log(` Source: ${chalk.cyan(options.file)}`);
|
|
43
|
-
|
|
43
|
+
diffCmd = `cat ${JSON.stringify(options.file)}`;
|
|
44
|
+
hasChanges = true;
|
|
44
45
|
} else {
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
const ref = options.diff || '';
|
|
47
|
+
try { hasChanges = hasDiff(ref); } catch { /* no diff yet is ok for workflow mode */ }
|
|
48
|
+
diffCmd = buildDiffCmd(ref);
|
|
49
|
+
if (hasChanges) console.log(` Diff cmd: ${chalk.cyan(diffCmd)}`);
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
if (options.specRef) console.log(` Spec: ${chalk.cyan(options.specRef)}`);
|
|
@@ -56,13 +59,13 @@ export async function reviewCommand(options) {
|
|
|
56
59
|
console.log('');
|
|
57
60
|
|
|
58
61
|
if (options.once) {
|
|
59
|
-
if (!
|
|
62
|
+
if (!hasChanges) {
|
|
60
63
|
console.log(chalk.yellow('No changes found. Stage some changes or specify --diff <ref>.'));
|
|
61
64
|
process.exit(0);
|
|
62
65
|
}
|
|
63
66
|
try {
|
|
64
67
|
console.log(chalk.dim('Starting review...\n'));
|
|
65
|
-
const review = await runReview(
|
|
68
|
+
const review = await runReview(diffCmd, { config, reviewerName, cwd, stream: true });
|
|
66
69
|
|
|
67
70
|
const session = createSession({ executor, reviewer: reviewerName, diff_ref: options.diff || 'auto' });
|
|
68
71
|
session.iterations.push({ round: 1, review, timestamp: new Date().toISOString() });
|
|
@@ -83,7 +86,7 @@ export async function reviewCommand(options) {
|
|
|
83
86
|
} else {
|
|
84
87
|
try {
|
|
85
88
|
const result = await runWorkflow({
|
|
86
|
-
config, executor, reviewerName, maxRounds,
|
|
89
|
+
config, executor, reviewerName, maxRounds, diffRef: options.diff,
|
|
87
90
|
taskDescription: options.task, specRef: options.specRef, tools: config.tools,
|
|
88
91
|
cwd, skipAnalyze, skipPlan,
|
|
89
92
|
onStageChange: (stage) => console.log(chalk.bold(`\n[${stageLabel(stage)}]\n`)),
|
package/src/mcp/mcp-server.js
CHANGED
|
@@ -6,7 +6,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
6
6
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { loadConfig, getReviewer } from '../config/config-loader.js';
|
|
9
|
-
import { getDiff } from '../tools/git-tools.js';
|
|
9
|
+
import { getDiff, hasDiff, buildDiffCmd } from '../tools/git-tools.js';
|
|
10
10
|
import { runToolGates } from '../tools/tool-runner.js';
|
|
11
11
|
import { runReview } from '../review/review-runner.js';
|
|
12
12
|
import { createSession, saveSession } from '../session/session-manager.js';
|
|
@@ -29,28 +29,20 @@ server.tool(
|
|
|
29
29
|
'TRIGGER: Use this tool when the user says "review", "review my code", "get a review", "check my changes", "openairev", or asks for independent/cross-model code review. Sends current code changes to a DIFFERENT AI model for independent review. The review starts in the background and returns immediately. After calling this, run `openairev wait` via Bash to stream progress and get the verdict — one blocking call, no polling needed.',
|
|
30
30
|
{
|
|
31
31
|
executor: z.string().optional().describe('Which agent wrote the code (claude_code or codex). If you are Claude Code, set this to "claude_code". If you are Codex, set this to "codex".'),
|
|
32
|
-
|
|
33
|
-
diff_cmd: z.string().optional().describe('The git command used to get the diff, e.g. "git diff HEAD -- src/auth.ts src/routes.ts". If provided instead of diff, the server will run this command to get the diff.'),
|
|
32
|
+
diff_cmd: z.string().optional().describe('The git diff command for the reviewer to run, e.g. "git diff HEAD -- src/auth.ts src/routes.ts". The reviewer runs in the same repo and executes this itself. If omitted, auto-detects staged or unstaged changes.'),
|
|
34
33
|
task_description: z.string().optional().describe('What the code is supposed to do. Used for requirement checking.'),
|
|
35
34
|
},
|
|
36
|
-
async ({ executor,
|
|
35
|
+
async ({ executor, diff_cmd, task_description }) => {
|
|
37
36
|
const execAgent = executor || Object.keys(config.agents || {}).find(a => config.agents[a].available);
|
|
38
37
|
const reviewerName = getReviewer(config, execAgent);
|
|
39
38
|
if (!reviewerName) {
|
|
40
39
|
return { content: [{ type: 'text', text: `No reviewer configured for executor "${execAgent}"` }] };
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
diffContent = execSync(diff_cmd, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, cwd });
|
|
48
|
-
} catch (e) {
|
|
49
|
-
return { content: [{ type: 'text', text: `diff_cmd failed: ${e.message}` }] };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
if (!diffContent) diffContent = getDiff();
|
|
53
|
-
if (!diffContent?.trim()) {
|
|
42
|
+
const reviewDiffCmd = diff_cmd || buildDiffCmd();
|
|
43
|
+
|
|
44
|
+
// Only validate when auto-detecting — if caller provided diff_cmd, trust it
|
|
45
|
+
if (!diff_cmd && !hasDiff()) {
|
|
54
46
|
return { content: [{ type: 'text', text: 'No changes found to review.' }] };
|
|
55
47
|
}
|
|
56
48
|
|
|
@@ -62,7 +54,7 @@ server.tool(
|
|
|
62
54
|
};
|
|
63
55
|
|
|
64
56
|
activeAbort = new AbortController();
|
|
65
|
-
activeReview = runReview(
|
|
57
|
+
activeReview = runReview(reviewDiffCmd, {
|
|
66
58
|
config,
|
|
67
59
|
reviewerName,
|
|
68
60
|
taskDescription: task_description,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createAdapter } from '../agents/registry.js';
|
|
2
2
|
import { runReview } from '../review/review-runner.js';
|
|
3
3
|
import { loadPromptFile } from '../review/prompt-loader.js';
|
|
4
|
-
import {
|
|
4
|
+
import { hasDiff, buildDiffCmd } from '../tools/git-tools.js';
|
|
5
5
|
import { runToolGates } from '../tools/tool-runner.js';
|
|
6
6
|
import {
|
|
7
7
|
createChain, transitionTo, addRound, setArtifact,
|
|
@@ -44,7 +44,7 @@ export async function runWorkflow({
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
let
|
|
47
|
+
let hasChanges = !!initialDiff;
|
|
48
48
|
let codeReviewCount = 0;
|
|
49
49
|
let planReviewCount = 0;
|
|
50
50
|
|
|
@@ -140,13 +140,7 @@ export async function runWorkflow({
|
|
|
140
140
|
const prompt = buildImplementationPrompt(chain, specRef);
|
|
141
141
|
await runExecutor(executor, config, prompt, chain, cwd);
|
|
142
142
|
|
|
143
|
-
|
|
144
|
-
currentDiff = getDiff(diffRef);
|
|
145
|
-
} catch {
|
|
146
|
-
currentDiff = '';
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (currentDiff?.trim()) {
|
|
143
|
+
if (hasDiff(diffRef)) {
|
|
150
144
|
setArtifact(chain, 'current_diff_ref', diffRef || 'auto', cwd);
|
|
151
145
|
transitionTo(chain, 'code_review', cwd);
|
|
152
146
|
} else {
|
|
@@ -168,7 +162,8 @@ export async function runWorkflow({
|
|
|
168
162
|
toolResults = runToolGates(Object.keys(tools), cwd, tools);
|
|
169
163
|
}
|
|
170
164
|
|
|
171
|
-
const
|
|
165
|
+
const reviewDiffCmd = diffRef ? `git diff ${diffRef}` : 'git diff --staged || git diff';
|
|
166
|
+
const review = await runReviewRound(reviewerName, config, reviewDiffCmd, {
|
|
172
167
|
kind: 'code_review', chain, specRef, cwd,
|
|
173
168
|
});
|
|
174
169
|
|
|
@@ -205,14 +200,7 @@ export async function runWorkflow({
|
|
|
205
200
|
const feedback = buildFeedback(lastVerdict, cwd);
|
|
206
201
|
await runExecutor(executor, config, feedback, chain, cwd);
|
|
207
202
|
|
|
208
|
-
|
|
209
|
-
currentDiff = getDiff(diffRef);
|
|
210
|
-
} catch (e) {
|
|
211
|
-
closeChain(chain, 'error', cwd);
|
|
212
|
-
return { chain, status: 'error', message: `Failed to get diff: ${e.message}` };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (!currentDiff?.trim()) {
|
|
203
|
+
if (!hasDiff(diffRef)) {
|
|
216
204
|
closeChain(chain, 'error', cwd);
|
|
217
205
|
return { chain, status: 'error', message: 'No changes after fix attempt' };
|
|
218
206
|
}
|
|
@@ -255,15 +243,16 @@ async function runExecutor(executor, config, prompt, chain, cwd) {
|
|
|
255
243
|
return { output: result?.result || result?.raw || null, session_id: sessionId };
|
|
256
244
|
}
|
|
257
245
|
|
|
258
|
-
async function runReviewRound(reviewerName, config,
|
|
246
|
+
async function runReviewRound(reviewerName, config, input, { kind, chain, specRef, cwd }) {
|
|
259
247
|
const promptFile = kind === 'plan_review' ? 'plan-reviewer.md' : 'reviewer.md';
|
|
260
248
|
const sessionId = getReviewerSession(chain, kind);
|
|
261
249
|
|
|
262
|
-
return runReview(
|
|
250
|
+
return runReview(input, {
|
|
263
251
|
config, reviewerName, promptFile,
|
|
264
252
|
taskDescription: chain.task?.user_request,
|
|
265
253
|
specRef: specRef || chain.task?.spec_ref,
|
|
266
254
|
cwd, sessionId, stream: true,
|
|
255
|
+
inputMode: kind === 'plan_review' ? 'inline' : 'diff_cmd',
|
|
267
256
|
});
|
|
268
257
|
}
|
|
269
258
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { createAdapter } from '../agents/registry.js';
|
|
4
|
-
import { stageInput, buildInputReference, compactDiff, getReviewerBudget, estimateTokens } from './input-stager.js';
|
|
5
4
|
import { loadPromptFile } from './prompt-loader.js';
|
|
6
5
|
|
|
7
|
-
export async function runReview(
|
|
6
|
+
export async function runReview(input, {
|
|
8
7
|
config,
|
|
9
8
|
reviewerName,
|
|
10
9
|
promptFile = 'reviewer.md',
|
|
@@ -14,6 +13,7 @@ export async function runReview(content, {
|
|
|
14
13
|
sessionId = null,
|
|
15
14
|
stream = false,
|
|
16
15
|
signal,
|
|
16
|
+
inputMode = 'diff_cmd',
|
|
17
17
|
}) {
|
|
18
18
|
const adapter = createAdapter(reviewerName, config, { cwd });
|
|
19
19
|
|
|
@@ -23,13 +23,6 @@ export async function runReview(content, {
|
|
|
23
23
|
|
|
24
24
|
const reviewerPrompt = loadPromptFile(promptFile, cwd);
|
|
25
25
|
|
|
26
|
-
// Compact diff if it exceeds the reviewer's context budget
|
|
27
|
-
const budget = getReviewerBudget(reviewerName);
|
|
28
|
-
const { content: compactedContent, compacted, stats } = compactDiff(content, { maxTokens: budget });
|
|
29
|
-
|
|
30
|
-
const staged = stageInput(compactedContent, { cwd });
|
|
31
|
-
const inputRef = buildInputReference(staged);
|
|
32
|
-
|
|
33
26
|
let prompt = reviewerPrompt;
|
|
34
27
|
if (taskDescription) {
|
|
35
28
|
prompt = `Task: ${taskDescription}\n\n${prompt}`;
|
|
@@ -37,7 +30,12 @@ export async function runReview(content, {
|
|
|
37
30
|
if (specRef) {
|
|
38
31
|
prompt += `\n\nSpec reference: ${specRef}\nRead the spec file for requirements and acceptance criteria.`;
|
|
39
32
|
}
|
|
40
|
-
|
|
33
|
+
|
|
34
|
+
if (inputMode === 'diff_cmd') {
|
|
35
|
+
prompt += `\n\n--- CHANGES TO REVIEW ---\nRun this command to get the diff:\n\`${input}\`\n\nReview the changed files. You are in the same repo as the executor — run the diff command yourself, read the files, and produce your verdict.`;
|
|
36
|
+
} else {
|
|
37
|
+
prompt += `\n\n--- CONTENT TO REVIEW ---\n${input}`;
|
|
38
|
+
}
|
|
41
39
|
|
|
42
40
|
const schemaFile = promptFile === 'plan-reviewer.md' ? 'plan-verdict-schema.json' : 'verdict-schema.json';
|
|
43
41
|
|
|
@@ -65,26 +63,12 @@ export async function runReview(content, {
|
|
|
65
63
|
session_id: adapter.sessionName || adapter.sessionId,
|
|
66
64
|
};
|
|
67
65
|
|
|
68
|
-
if (compacted) {
|
|
69
|
-
reviewResult.context_stats = stats;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Mark partial reviews so callers know files were omitted
|
|
73
|
-
if (compacted && stats.filesDropped > 0 && verdict) {
|
|
74
|
-
reviewResult.partial = true;
|
|
75
|
-
reviewResult.partial_notice = `PARTIAL REVIEW: ${stats.filesDropped} files were omitted to fit the reviewer's context budget (${stats.droppedFiles.join(', ')}). Re-run with a smaller diff to cover them.`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Diagnose null verdicts — check for explicit errors before blaming context budget
|
|
79
66
|
if (!verdict) {
|
|
80
67
|
const explicitError = result?.error;
|
|
81
68
|
if (explicitError) {
|
|
82
69
|
reviewResult.error = `Reviewer (${reviewerName}) failed: ${explicitError}`;
|
|
83
70
|
} else {
|
|
84
|
-
reviewResult.error = `Reviewer (${reviewerName}) produced no verdict.
|
|
85
|
-
`Diff was ~${stats.originalTokens} tokens` +
|
|
86
|
-
(compacted ? `, compacted to ~${stats.finalTokens} tokens (${stats.filesDropped} files dropped)` : '') +
|
|
87
|
-
`. Possible causes: context budget exceeded, auth failure, or schema mismatch. Check .openairev/logs/ for details.`;
|
|
71
|
+
reviewResult.error = `Reviewer (${reviewerName}) produced no verdict. Possible causes: context budget exceeded, auth failure, or schema mismatch. Check .openairev/logs/ for details.`;
|
|
88
72
|
}
|
|
89
73
|
}
|
|
90
74
|
|
package/src/tools/git-tools.js
CHANGED
|
@@ -34,6 +34,38 @@ export function getDiff(ref, { context = 1, excludes = EXCLUDE_PATTERNS } = {})
|
|
|
34
34
|
return '';
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Check if there are changes without reading the diff content.
|
|
39
|
+
* Uses git diff --quiet (exit code only, no output).
|
|
40
|
+
*/
|
|
41
|
+
export function hasDiff(ref) {
|
|
42
|
+
try {
|
|
43
|
+
if (ref) {
|
|
44
|
+
execFileSync('git', ['diff', '--quiet', ref]);
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
// Try staged first
|
|
48
|
+
try {
|
|
49
|
+
execFileSync('git', ['diff', '--quiet', '--cached']);
|
|
50
|
+
// exit 0 = no staged changes, try unstaged
|
|
51
|
+
execFileSync('git', ['diff', '--quiet']);
|
|
52
|
+
return false; // no changes at all
|
|
53
|
+
} catch {
|
|
54
|
+
return true; // either staged or unstaged has changes
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
return true; // exit 1 = has changes
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a diff command string for the reviewer to run.
|
|
63
|
+
*/
|
|
64
|
+
export function buildDiffCmd(ref) {
|
|
65
|
+
if (ref) return `git diff ${ref}`;
|
|
66
|
+
return 'git diff --staged || git diff';
|
|
67
|
+
}
|
|
68
|
+
|
|
37
69
|
function gitExec(args) {
|
|
38
70
|
try {
|
|
39
71
|
return execFileSync('git', args, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
3
|
-
|
|
4
|
-
const INLINE_THRESHOLD = 8_000; // characters — inline if under this
|
|
5
|
-
|
|
6
|
-
// Rough estimate: ~4 characters per token for code/diffs
|
|
7
|
-
const CHARS_PER_TOKEN = 4;
|
|
8
|
-
|
|
9
|
-
// Reviewer context budgets (in tokens). Leave room for prompt, schema, and output.
|
|
10
|
-
// These are conservative — better to compact than to blow the budget.
|
|
11
|
-
const REVIEWER_BUDGETS = {
|
|
12
|
-
codex: 100_000, // Codex has ~200k context, reserve half for output + tools
|
|
13
|
-
claude_code: 150_000, // Claude has ~200k context, needs less reserve
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const DEFAULT_BUDGET = 100_000;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Estimate token count from character length.
|
|
20
|
-
*/
|
|
21
|
-
export function estimateTokens(text) {
|
|
22
|
-
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Get the token budget for a reviewer.
|
|
27
|
-
*/
|
|
28
|
-
export function getReviewerBudget(reviewerName) {
|
|
29
|
-
return REVIEWER_BUDGETS[reviewerName] || DEFAULT_BUDGET;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Compact a diff to fit within a token budget.
|
|
34
|
-
* Strategy: parse into per-file hunks, sort by size ascending,
|
|
35
|
-
* drop the largest files first until it fits.
|
|
36
|
-
* Returns { content, compacted, stats }.
|
|
37
|
-
*/
|
|
38
|
-
export function compactDiff(diff, { maxTokens }) {
|
|
39
|
-
const tokens = estimateTokens(diff);
|
|
40
|
-
if (tokens <= maxTokens) {
|
|
41
|
-
return { content: diff, compacted: false, partial: false, stats: { originalTokens: tokens, finalTokens: tokens, filesDropped: 0 } };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const files = parseDiffFiles(diff);
|
|
45
|
-
// Sort by size descending — drop largest first
|
|
46
|
-
files.sort((a, b) => b.content.length - a.content.length);
|
|
47
|
-
|
|
48
|
-
// Reserve chars for the omission notice (worst case)
|
|
49
|
-
const noticeOverhead = 500;
|
|
50
|
-
const maxChars = (maxTokens * CHARS_PER_TOKEN) - noticeOverhead;
|
|
51
|
-
let totalChars = diff.length;
|
|
52
|
-
const dropped = [];
|
|
53
|
-
|
|
54
|
-
while (totalChars > maxChars && files.length > 1) {
|
|
55
|
-
const largest = files.shift();
|
|
56
|
-
totalChars -= largest.content.length;
|
|
57
|
-
dropped.push(largest.name);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// If single file still too large, truncate it
|
|
61
|
-
if (files.length === 1 && totalChars > maxChars) {
|
|
62
|
-
const file = files[0];
|
|
63
|
-
const truncateAt = maxChars - 200;
|
|
64
|
-
file.content = file.content.slice(0, Math.max(0, truncateAt)) + `\n\n... [TRUNCATED — file too large for reviewer context] ...\n`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
let compactedDiff = files.map(f => f.content).join('\n');
|
|
68
|
-
if (dropped.length > 0) {
|
|
69
|
-
const notice = `\n\n--- FILES OMITTED (too large for reviewer context) ---\n${dropped.map(f => ` ${f}`).join('\n')}\n--- This is a PARTIAL review. Only the files below were included. ---\n`;
|
|
70
|
-
compactedDiff = notice + compactedDiff;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const finalTokens = estimateTokens(compactedDiff);
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
content: compactedDiff,
|
|
77
|
-
compacted: true,
|
|
78
|
-
partial: dropped.length > 0,
|
|
79
|
-
stats: {
|
|
80
|
-
originalTokens: tokens,
|
|
81
|
-
finalTokens,
|
|
82
|
-
filesDropped: dropped.length,
|
|
83
|
-
droppedFiles: dropped,
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Parse a unified diff into per-file sections.
|
|
90
|
-
*/
|
|
91
|
-
function parseDiffFiles(diff) {
|
|
92
|
-
const files = [];
|
|
93
|
-
// Split on diff headers (diff --git, or --- a/ lines preceded by blank)
|
|
94
|
-
const parts = diff.split(/(?=^diff --git )/m);
|
|
95
|
-
|
|
96
|
-
for (const part of parts) {
|
|
97
|
-
if (!part.trim()) continue;
|
|
98
|
-
const nameMatch = part.match(/^diff --git a\/(.+?) b\//m)
|
|
99
|
-
|| part.match(/^---\s+a\/(.+)/m)
|
|
100
|
-
|| part.match(/^\+\+\+\s+b\/(.+)/m);
|
|
101
|
-
const name = nameMatch ? nameMatch[1] : 'unknown';
|
|
102
|
-
files.push({ name, content: part });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return files;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Stage review input. For small content, returns it inline.
|
|
110
|
-
* For large content, writes to .openairev/tmp/ and returns a file reference.
|
|
111
|
-
*/
|
|
112
|
-
export function stageInput(content, { cwd = process.cwd(), label = 'review-input' } = {}) {
|
|
113
|
-
if (content.length <= INLINE_THRESHOLD) {
|
|
114
|
-
return { mode: 'inline', content };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const tmpDir = join(cwd, '.openairev', 'tmp');
|
|
118
|
-
mkdirSync(tmpDir, { recursive: true });
|
|
119
|
-
|
|
120
|
-
const filename = `${label}-${Date.now()}.diff`;
|
|
121
|
-
const filePath = join(tmpDir, filename);
|
|
122
|
-
const relativePath = `.openairev/tmp/${filename}`;
|
|
123
|
-
|
|
124
|
-
writeFileSync(filePath, content);
|
|
125
|
-
|
|
126
|
-
return { mode: 'file', filePath, relativePath };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Build the prompt prefix for the first pass based on staging result.
|
|
131
|
-
*/
|
|
132
|
-
export function buildInputReference(staged) {
|
|
133
|
-
if (staged.mode === 'inline') {
|
|
134
|
-
return `\n\n--- DIFF ---\n${staged.content}`;
|
|
135
|
-
}
|
|
136
|
-
return `\n\nThe diff to review is stored at: ${staged.relativePath}\nRead that file to see the full changes. It is too large to include inline.`;
|
|
137
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdirSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
4
|
-
import { join } from 'path';
|
|
5
|
-
import { stageInput, buildInputReference } from './input-stager.js';
|
|
6
|
-
|
|
7
|
-
const TMP = join(process.cwd(), '.test-tmp-stager');
|
|
8
|
-
|
|
9
|
-
describe('input-stager', () => {
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
mkdirSync(TMP, { recursive: true });
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
rmSync(TMP, { recursive: true, force: true });
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('inlines small content', () => {
|
|
19
|
-
const result = stageInput('small diff', { cwd: TMP });
|
|
20
|
-
assert.equal(result.mode, 'inline');
|
|
21
|
-
assert.equal(result.content, 'small diff');
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it('writes large content to file', () => {
|
|
25
|
-
const large = 'x'.repeat(10_000);
|
|
26
|
-
const result = stageInput(large, { cwd: TMP });
|
|
27
|
-
assert.equal(result.mode, 'file');
|
|
28
|
-
assert.ok(result.filePath);
|
|
29
|
-
assert.ok(result.relativePath.startsWith('.openairev/tmp/'));
|
|
30
|
-
assert.ok(existsSync(result.filePath));
|
|
31
|
-
assert.equal(readFileSync(result.filePath, 'utf-8'), large);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('buildInputReference returns inline content for small input', () => {
|
|
35
|
-
const staged = { mode: 'inline', content: 'diff here' };
|
|
36
|
-
const ref = buildInputReference(staged);
|
|
37
|
-
assert.ok(ref.includes('diff here'));
|
|
38
|
-
assert.ok(ref.includes('--- DIFF ---'));
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('buildInputReference returns file path for large input', () => {
|
|
42
|
-
const staged = { mode: 'file', relativePath: '.openairev/tmp/test.diff' };
|
|
43
|
-
const ref = buildInputReference(staged);
|
|
44
|
-
assert.ok(ref.includes('.openairev/tmp/test.diff'));
|
|
45
|
-
assert.ok(ref.includes('Read that file'));
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('uses custom label for filename', () => {
|
|
49
|
-
const large = 'y'.repeat(10_000);
|
|
50
|
-
const result = stageInput(large, { cwd: TMP, label: 'my-review' });
|
|
51
|
-
assert.ok(result.relativePath.includes('my-review'));
|
|
52
|
-
});
|
|
53
|
-
});
|