Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { toolRegistry } from './registry.js';
import { askCodexTool } from './ask-codex.tool.js';
import { batchCodexTool } from './batch-codex.tool.js';
// import { reviewCodexTool } from './review-codex.tool.js';
import { reviewCodexTool } from './review-codex.tool.js';
import { pingTool, helpTool, versionTool } from './simple-tools.js';
import { brainstormTool } from './brainstorm.tool.js';
import { fetchChunkTool } from './fetch-chunk.tool.js';
Expand All @@ -11,7 +11,7 @@ import { timeoutTestTool } from './timeout-test.tool.js';
toolRegistry.push(
askCodexTool,
batchCodexTool,
// reviewCodexTool,
reviewCodexTool,
pingTool,
helpTool,
versionTool,
Expand Down
272 changes: 272 additions & 0 deletions src/tools/review-codex.tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { z } from 'zod';
import { UnifiedTool } from './registry.js';
import { executeCommandDetailed } from '../utils/commandExecutor.js';
import { CLI } from '../constants.js';
import { Logger } from '../utils/logger.js';

const reviewCodexArgsSchema = z.object({
uncommitted: z
.boolean()
.optional()
.describe('Review staged, unstaged, and untracked changes'),
commit: z
.string()
.optional()
.describe('Review the changes introduced by a specific commit SHA'),
base: z
.string()
.optional()
.describe('Review changes against the given base branch'),
title: z
.string()
.optional()
.describe('Optional commit title to display in the review summary'),
prompt: z
.string()
.optional()
.describe('Custom review instructions'),
config: z
.string()
.optional()
.describe("Configuration override as 'key=value' string"),
enableFeatures: z
.array(z.string())
.optional()
.describe('Enable feature flags'),
disableFeatures: z
.array(z.string())
.optional()
.describe('Disable feature flags'),
workingDir: z
.string()
.optional()
.describe('Working directory (must be a git repository)'),
timeout: z
.number()
.optional()
.describe('Maximum execution time in milliseconds'),
debug: z
.boolean()
.optional()
.describe('Return raw output for debugging'),
});

export const reviewCodexTool: UnifiedTool = {
name: 'review',
description:
'Run Codex code review on git changes. Supports reviewing uncommitted changes, specific commits, or branch comparisons.',
zodSchema: reviewCodexArgsSchema,
prompt: {
description: 'Run code review with Codex CLI',
},
category: 'utility',
execute: async (args, onProgress) => {
const {
uncommitted,
commit,
base,
title,
prompt,
config,
enableFeatures,
disableFeatures,
workingDir,
timeout,
debug,
} = args;

// Validate: at least one review target must be specified
if (!uncommitted && !commit && !base) {
return `❌ **Review Target Required**

Please specify what to review:
- \`uncommitted: true\` - Review staged, unstaged, and untracked changes
- \`commit: "<SHA>"\` - Review a specific commit
- \`base: "<branch>"\` - Review changes against a base branch

**Examples:**
\`\`\`json
{ "uncommitted": true }
{ "commit": "abc1234" }
{ "base": "main" }
\`\`\``;
}

const cmdArgs: string[] = ['review'];
const cwd = (workingDir as string) || process.cwd();

// Review target options
if (uncommitted) {
cmdArgs.push('--uncommitted');
}
if (commit) {
cmdArgs.push('--commit', commit as string);
}
if (base) {
cmdArgs.push('--base', base as string);
}

// Optional title
if (title) {
cmdArgs.push('--title', title as string);
}

// Config override
if (config && typeof config === 'string') {
cmdArgs.push('-c', config);
}

// Feature flags
if (enableFeatures && Array.isArray(enableFeatures)) {
for (const feature of enableFeatures) {
cmdArgs.push('--enable', feature);
}
}
if (disableFeatures && Array.isArray(disableFeatures)) {
for (const feature of disableFeatures) {
cmdArgs.push('--disable', feature);
}
}

// Custom prompt (must be last)
if (prompt) {
cmdArgs.push(prompt);
}

try {
const timeoutMs = timeout || 600000; // 10 minutes default

Logger.debug(`Executing codex review in ${cwd}`);
Logger.debug(`Args: ${cmdArgs.join(' ')}`);

const result = await executeCommandDetailed(CLI.COMMANDS.CODEX, cmdArgs, {
onProgress,
timeoutMs,
cwd,
});

// Debug mode: return raw output
if (debug) {
return `**Debug Output:**

ok: ${result.ok}
code: ${result.code}
stdout length: ${result.stdout?.length || 0}
stderr: ${result.stderr || '(empty)'}

Raw stdout:
${result.stdout || '(empty)'}`;
}

// Codex outputs to stderr, check both stdout and stderr
const output = result.stdout || result.stderr || '';

if (output) {
return formatReviewOutput(output);
}

// Handle errors
if (!result.ok) {
const errorMessage = result.stderr || `Exit code: ${result.code}`;
return `❌ **Review Failed**

${errorMessage}

**Debug Info:**
- Working directory: ${cwd}
- Command: codex ${cmdArgs.join(' ')}`;
}

return `ℹ️ **Review Complete**

No issues found or no output generated.

**Debug Info:**
- Working directory: ${cwd}
- Command: codex ${cmdArgs.join(' ')}`;
} catch (error) {
Logger.error('Codex review failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return `❌ **Review Error**: ${errorMessage}`;
}
},
};

function formatReviewOutput(output: string): string {
if (!output || output.trim() === '') {
return 'ℹ️ **Review Complete** - No issues found.';
}

// Extract the main review content from codex output
const lines = output.split('\n');
const reviewContent: string[] = [];
let inThinking = false;
let thinkingContent: string[] = [];

for (const line of lines) {
// Skip metadata lines
if (
line.startsWith('OpenAI Codex') ||
line.startsWith('--------') ||
line.startsWith('workdir:') ||
line.startsWith('model:') ||
line.startsWith('provider:') ||
line.startsWith('approval:') ||
line.startsWith('sandbox:') ||
line.startsWith('reasoning') ||
line.startsWith('session id:') ||
line.startsWith('user') ||
line.startsWith('mcp startup:')
) {
continue;
}

// Track thinking blocks
if (line.startsWith('thinking')) {
inThinking = true;
const thinkingText = line.replace(/^thinking\s*/, '').trim();
if (thinkingText) {
thinkingContent.push(thinkingText);
}
continue;
}

// Track exec blocks (command execution)
if (line.startsWith('exec')) {
inThinking = false;
continue;
}

// Track codex output (final review)
if (line.startsWith('codex')) {
inThinking = false;
const codexText = line.replace(/^codex\s*/, '').trim();
if (codexText) {
reviewContent.push(codexText);
}
continue;
}

// Collect content
if (inThinking) {
thinkingContent.push(line);
} else if (line.trim()) {
reviewContent.push(line);
}
}

// Build formatted output
let formatted = '## 📋 Code Review Results\n\n';

if (reviewContent.length > 0) {
formatted += reviewContent.join('\n');
} else if (thinkingContent.length > 0) {
// Fallback to thinking content if no explicit review output
formatted += '### Analysis\n\n';
formatted += thinkingContent.join('\n');
} else {
formatted += 'No specific issues identified.';
}

return formatted;
}
6 changes: 5 additions & 1 deletion src/utils/commandExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ExecuteOptions {
timeoutMs?: number;
maxOutputBytes?: number;
retry?: RetryOptions;
cwd?: string;
}

/**
Expand All @@ -37,6 +38,7 @@ export async function executeCommandDetailed(
timeoutMs = 600000,
maxOutputBytes = 50 * 1024 * 1024, // 50MB default
retry,
cwd,
} = options;

let attempt = 0;
Expand All @@ -48,6 +50,7 @@ export async function executeCommandDetailed(
onProgress,
timeoutMs,
maxOutputBytes,
cwd,
});

if (result.ok) {
Expand Down Expand Up @@ -77,14 +80,15 @@ export async function executeCommandDetailed(
async function executeOnce(
command: string,
args: string[],
{ onProgress, timeoutMs, maxOutputBytes }: Omit<ExecuteOptions, 'retry'>
{ onProgress, timeoutMs, maxOutputBytes, cwd }: Omit<ExecuteOptions, 'retry'>
): Promise<CommandResult> {
return new Promise(resolve => {
const startTime = Date.now();
Logger.commandExecution(command, args, startTime);

const childProcess = spawn(command, args, {
env: process.env,
cwd: cwd || process.cwd(),
// cross-spawn automatically handles shell mode and .cmd extensions on Windows
stdio: ['ignore', 'pipe', 'pipe'],
});
Expand Down