feat: add CodeRabbit integration for AI-powered code reviews

This commit introduces the CodeRabbit service and its associated routes, enabling users to trigger, manage, and check the status of code reviews through a new API. Key features include:

- New routes for triggering code reviews, checking status, and stopping reviews.
- Integration with the CodeRabbit CLI for authentication and status checks.
- UI components for displaying code review results and settings management.
- Unit tests for the new code review functionality to ensure reliability.

This enhancement aims to streamline the code review process and leverage AI capabilities for improved code quality.
This commit is contained in:
Shirone
2026-01-24 21:10:33 +01:00
parent 327aef89a2
commit 5b620011ad
44 changed files with 6582 additions and 8 deletions

View File

@@ -83,6 +83,8 @@ import { createNotificationsRoutes } from './routes/notifications/index.js';
import { getNotificationService } from './services/notification-service.js';
import { createEventHistoryRoutes } from './routes/event-history/index.js';
import { getEventHistoryService } from './services/event-history-service.js';
import { createCodeReviewRoutes } from './routes/code-review/index.js';
import { CodeReviewService } from './services/code-review-service.js';
// Load environment variables
dotenv.config();
@@ -209,6 +211,7 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
const codexUsageService = new CodexUsageService(codexAppServerService);
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
const codeReviewService = new CodeReviewService(events, settingsService);
// Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService();
@@ -300,6 +303,7 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
app.use('/api/notifications', createNotificationsRoutes(notificationService));
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
app.use('/api/code-review', createCodeReviewRoutes(codeReviewService));
// Create HTTP server
const server = createServer(app);

View File

@@ -40,6 +40,7 @@ export interface UnifiedCliDetection {
claude?: CliDetectionResult;
codex?: CliDetectionResult;
cursor?: CliDetectionResult;
coderabbit?: CliDetectionResult;
}
/**
@@ -76,6 +77,16 @@ const CLI_CONFIGS = {
win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex',
},
},
coderabbit: {
name: 'CodeRabbit CLI',
commands: ['coderabbit', 'cr'],
versionArgs: ['--version'],
installCommands: {
darwin: 'npm install -g coderabbit',
linux: 'npm install -g coderabbit',
win32: 'npm install -g coderabbit',
},
},
} as const;
/**
@@ -230,6 +241,8 @@ export async function checkCliAuth(
return await checkCodexAuth(command);
case 'cursor':
return await checkCursorAuth(command);
case 'coderabbit':
return await checkCodeRabbitAuth(command);
default:
return 'none';
}
@@ -355,6 +368,64 @@ async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'no
return 'none';
}
/**
* Check CodeRabbit CLI authentication
*
* Expected output when authenticated:
* ```
* CodeRabbit CLI Status
* ✅ Authentication: Logged in
* User Information:
* 👤 Name: ...
* ```
*/
async function checkCodeRabbitAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
// Check for environment variable
if (process.env.CODERABBIT_API_KEY) {
return 'api_key';
}
// Try running auth status command
return new Promise((resolve) => {
const child = spawn(command, ['auth', 'status'], {
stdio: 'pipe',
timeout: 10000, // Increased timeout for slower systems
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const output = stdout + stderr;
// Check for positive authentication indicators in output
const isAuthenticated =
code === 0 &&
(output.includes('Logged in') || output.includes('logged in')) &&
!output.toLowerCase().includes('not logged in') &&
!output.toLowerCase().includes('not authenticated');
if (isAuthenticated) {
resolve('cli');
} else {
resolve('none');
}
});
child.on('error', () => {
resolve('none');
});
});
}
/**
* Get installation instructions for a provider
*/

View File

@@ -0,0 +1,78 @@
/**
* Common utilities for code-review routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
const logger = createLogger('CodeReview');
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);
/**
* Review state interface
*/
interface ReviewState {
isRunning: boolean;
abortController: AbortController | null;
projectPath: string | null;
}
/**
* Shared state for code review operations
* Using an object to avoid mutable `let` exports which can cause issues in ES modules
*/
const reviewState: ReviewState = {
isRunning: false,
abortController: null,
projectPath: null,
};
/**
* Check if a review is currently running
*/
export function isRunning(): boolean {
return reviewState.isRunning;
}
/**
* Get the current abort controller (for stopping reviews)
*/
export function getAbortController(): AbortController | null {
return reviewState.abortController;
}
/**
* Get the current project path being reviewed
*/
export function getCurrentProjectPath(): string | null {
return reviewState.projectPath;
}
/**
* Set the running state for code review operations
*/
export function setRunningState(
running: boolean,
controller: AbortController | null = null,
projectPath: string | null = null
): void {
reviewState.isRunning = running;
reviewState.abortController = controller;
reviewState.projectPath = projectPath;
}
/**
* Get the current review status
*/
export function getReviewStatus(): {
isRunning: boolean;
projectPath: string | null;
} {
return {
isRunning: reviewState.isRunning,
projectPath: reviewState.projectPath,
};
}

View File

@@ -0,0 +1,40 @@
/**
* Code Review routes - HTTP API for triggering and managing code reviews
*
* Provides endpoints for:
* - Triggering code reviews on projects
* - Checking review status
* - Stopping in-progress reviews
*
* Uses the CodeReviewService for actual review execution with AI providers.
*/
import { Router } from 'express';
import type { CodeReviewService } from '../../services/code-review-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createTriggerHandler } from './routes/trigger.js';
import { createStatusHandler } from './routes/status.js';
import { createStopHandler } from './routes/stop.js';
import { createProvidersHandler } from './routes/providers.js';
export function createCodeReviewRoutes(codeReviewService: CodeReviewService): Router {
const router = Router();
// POST /trigger - Start a new code review
router.post(
'/trigger',
validatePathParams('projectPath'),
createTriggerHandler(codeReviewService)
);
// GET /status - Get current review status
router.get('/status', createStatusHandler());
// POST /stop - Stop current review
router.post('/stop', createStopHandler());
// GET /providers - Get available providers and their status
router.get('/providers', createProvidersHandler(codeReviewService));
return router;
}

View File

@@ -0,0 +1,38 @@
/**
* GET /providers endpoint - Get available code review providers
*
* Returns the status of all available AI providers that can be used for code reviews.
*/
import type { Request, Response } from 'express';
import type { CodeReviewService } from '../../../services/code-review-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('CodeReview');
export function createProvidersHandler(codeReviewService: CodeReviewService) {
return async (req: Request, res: Response): Promise<void> => {
logger.debug('========== /providers endpoint called ==========');
try {
// Check if refresh is requested
const forceRefresh = req.query.refresh === 'true';
const providers = await codeReviewService.getProviderStatus(forceRefresh);
const bestProvider = await codeReviewService.getBestProvider();
res.json({
success: true,
providers,
recommended: bestProvider,
});
} catch (error) {
logError(error, 'Providers handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,32 @@
/**
* GET /status endpoint - Get current code review status
*
* Returns whether a code review is currently running and which project.
*/
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import { getReviewStatus, getErrorMessage, logError } from '../common.js';
const logger = createLogger('CodeReview');
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
logger.debug('========== /status endpoint called ==========');
try {
const status = getReviewStatus();
res.json({
success: true,
...status,
});
} catch (error) {
logError(error, 'Status handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,54 @@
/**
* POST /stop endpoint - Stop the current code review
*
* Aborts any running code review operation.
*/
import type { Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import {
isRunning,
getAbortController,
setRunningState,
getErrorMessage,
logError,
} from '../common.js';
const logger = createLogger('CodeReview');
export function createStopHandler() {
return async (_req: Request, res: Response): Promise<void> => {
logger.info('========== /stop endpoint called ==========');
try {
if (!isRunning()) {
res.json({
success: true,
message: 'No code review is currently running',
});
return;
}
// Abort the current operation
const abortController = getAbortController();
if (abortController) {
abortController.abort();
logger.info('Code review aborted');
}
// Reset state
setRunningState(false, null, null);
res.json({
success: true,
message: 'Code review stopped',
});
} catch (error) {
logError(error, 'Stop handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,188 @@
/**
* POST /trigger endpoint - Trigger a code review
*
* Starts an asynchronous code review on the specified project.
* Progress updates are streamed via WebSocket events.
*/
import type { Request, Response } from 'express';
import type { CodeReviewService } from '../../../services/code-review-service.js';
import type { CodeReviewCategory, ThinkingLevel, ModelId } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import { isRunning, setRunningState, getErrorMessage, logError } from '../common.js';
const logger = createLogger('CodeReview');
/**
* Maximum number of files allowed per review request
*/
const MAX_FILES_PER_REQUEST = 100;
/**
* Maximum length for baseRef parameter
*/
const MAX_BASE_REF_LENGTH = 256;
/**
* Valid categories for code review
*/
const VALID_CATEGORIES: CodeReviewCategory[] = [
'tech_stack',
'security',
'code_quality',
'implementation',
'architecture',
'performance',
'testing',
'documentation',
];
/**
* Valid thinking levels
*/
const VALID_THINKING_LEVELS: ThinkingLevel[] = ['low', 'medium', 'high'];
interface TriggerRequestBody {
projectPath: string;
files?: string[];
baseRef?: string;
categories?: CodeReviewCategory[];
autoFix?: boolean;
model?: ModelId;
thinkingLevel?: ThinkingLevel;
}
/**
* Validate and sanitize the request body
*/
function validateRequestBody(body: TriggerRequestBody): { valid: boolean; error?: string } {
const { files, baseRef, categories, autoFix, thinkingLevel } = body;
// Validate files array
if (files !== undefined) {
if (!Array.isArray(files)) {
return { valid: false, error: 'files must be an array' };
}
if (files.length > MAX_FILES_PER_REQUEST) {
return { valid: false, error: `Maximum ${MAX_FILES_PER_REQUEST} files allowed per request` };
}
for (const file of files) {
if (typeof file !== 'string') {
return { valid: false, error: 'Each file must be a string' };
}
if (file.length > 500) {
return { valid: false, error: 'File path too long' };
}
}
}
// Validate baseRef
if (baseRef !== undefined) {
if (typeof baseRef !== 'string') {
return { valid: false, error: 'baseRef must be a string' };
}
if (baseRef.length > MAX_BASE_REF_LENGTH) {
return { valid: false, error: 'baseRef is too long' };
}
}
// Validate categories
if (categories !== undefined) {
if (!Array.isArray(categories)) {
return { valid: false, error: 'categories must be an array' };
}
for (const category of categories) {
if (!VALID_CATEGORIES.includes(category)) {
return { valid: false, error: `Invalid category: ${category}` };
}
}
}
// Validate autoFix
if (autoFix !== undefined && typeof autoFix !== 'boolean') {
return { valid: false, error: 'autoFix must be a boolean' };
}
// Validate thinkingLevel
if (thinkingLevel !== undefined) {
if (!VALID_THINKING_LEVELS.includes(thinkingLevel)) {
return { valid: false, error: `Invalid thinkingLevel: ${thinkingLevel}` };
}
}
return { valid: true };
}
export function createTriggerHandler(codeReviewService: CodeReviewService) {
return async (req: Request, res: Response): Promise<void> => {
logger.info('========== /trigger endpoint called ==========');
try {
const body = req.body as TriggerRequestBody;
const { projectPath, files, baseRef, categories, autoFix, model, thinkingLevel } = body;
// Validate required parameters
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath is required',
});
return;
}
// SECURITY: Validate all input parameters
const validation = validateRequestBody(body);
if (!validation.valid) {
res.status(400).json({
success: false,
error: validation.error,
});
return;
}
// Check if a review is already running
if (isRunning()) {
res.status(409).json({
success: false,
error: 'A code review is already in progress',
});
return;
}
// Set up abort controller for cancellation
const abortController = new AbortController();
setRunningState(true, abortController, projectPath);
// Start the review in the background
codeReviewService
.executeReview({
projectPath,
files,
baseRef,
categories,
autoFix,
model,
thinkingLevel,
abortController,
})
.catch((error) => {
logError(error, 'Code review failed');
})
.finally(() => {
setRunningState(false, null, null);
});
// Return immediate response
res.json({
success: true,
message: 'Code review started',
});
} catch (error) {
logError(error, 'Trigger handler exception');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -3,6 +3,7 @@
*/
import { Router } from 'express';
import { createStatusHandler } from './routes/status.js';
import { createClaudeStatusHandler } from './routes/claude-status.js';
import { createInstallClaudeHandler } from './routes/install-claude.js';
import { createAuthClaudeHandler } from './routes/auth-claude.js';
@@ -12,6 +13,10 @@ import { createApiKeysHandler } from './routes/api-keys.js';
import { createPlatformHandler } from './routes/platform.js';
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js';
import { createVerifyCodeRabbitAuthHandler } from './routes/verify-coderabbit-auth.js';
import { createCodeRabbitStatusHandler } from './routes/coderabbit-status.js';
import { createAuthCodeRabbitHandler } from './routes/auth-coderabbit.js';
import { createDeauthCodeRabbitHandler } from './routes/deauth-coderabbit.js';
import { createGhStatusHandler } from './routes/gh-status.js';
import { createCursorStatusHandler } from './routes/cursor-status.js';
import { createCodexStatusHandler } from './routes/codex-status.js';
@@ -44,6 +49,9 @@ import {
export function createSetupRoutes(): Router {
const router = Router();
// Unified CLI status endpoint
router.get('/status', createStatusHandler());
router.get('/claude-status', createClaudeStatusHandler());
router.post('/install-claude', createInstallClaudeHandler());
router.post('/auth-claude', createAuthClaudeHandler());
@@ -54,6 +62,7 @@ export function createSetupRoutes(): Router {
router.get('/platform', createPlatformHandler());
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
router.post('/verify-coderabbit-auth', createVerifyCodeRabbitAuthHandler());
router.get('/gh-status', createGhStatusHandler());
// Cursor CLI routes
@@ -72,6 +81,11 @@ export function createSetupRoutes(): Router {
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());
// CodeRabbit CLI routes
router.get('/coderabbit-status', createCodeRabbitStatusHandler());
router.post('/auth-coderabbit', createAuthCodeRabbitHandler());
router.post('/deauth-coderabbit', createDeauthCodeRabbitHandler());
// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());

View File

@@ -0,0 +1,80 @@
/**
* POST /auth-coderabbit endpoint - Authenticate CodeRabbit CLI via OAuth
*
* CodeRabbit CLI requires interactive authentication:
* 1. Run `cr auth login`
* 2. Browser opens with OAuth flow
* 3. After browser auth, CLI shows a token
* 4. User must press Enter to confirm
*
* Since step 4 requires interactive input, we can't fully automate this.
* Instead, we provide the command for the user to run manually.
*/
import type { Request, Response } from 'express';
import { execSync } from 'child_process';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Find the CodeRabbit CLI command (coderabbit or cr)
*/
function findCodeRabbitCommand(): string | null {
const commands = ['coderabbit', 'cr'];
for (const command of commands) {
try {
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
const result = execSync(`${whichCommand} ${command}`, {
encoding: 'utf8',
timeout: 2000,
}).trim();
if (result) {
return result.split('\n')[0];
}
} catch {
// Command not found, try next
}
}
return null;
}
export function createAuthCodeRabbitHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Remove the disconnected marker file to reconnect the app to the CLI
const markerPath = path.join(process.cwd(), '.automaker', '.coderabbit-disconnected');
if (fs.existsSync(markerPath)) {
fs.unlinkSync(markerPath);
}
// Find CodeRabbit CLI
const cliPath = findCodeRabbitCommand();
if (!cliPath) {
res.status(400).json({
success: false,
error: 'CodeRabbit CLI is not installed. Please install it first.',
});
return;
}
// CodeRabbit CLI requires interactive input (pressing Enter after OAuth)
// We can't automate this, so we return the command for the user to run
const command = cliPath.includes('coderabbit') ? 'coderabbit auth login' : 'cr auth login';
res.json({
success: true,
requiresManualAuth: true,
command,
message: `Please run "${command}" in your terminal to authenticate. After completing OAuth in your browser, press Enter in the terminal to confirm.`,
});
} catch (error) {
logError(error, 'Auth CodeRabbit failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to initiate CodeRabbit authentication',
});
}
};
}

View File

@@ -0,0 +1,240 @@
/**
* GET /coderabbit-status endpoint - Get CodeRabbit CLI installation and auth status
*/
import type { Request, Response } from 'express';
import { spawn, execSync } from 'child_process';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
const DISCONNECTED_MARKER_FILE = '.coderabbit-disconnected';
function isCodeRabbitDisconnectedFromApp(): boolean {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
return fs.existsSync(markerPath);
} catch {
return false;
}
}
/**
* Find the CodeRabbit CLI command (coderabbit or cr)
*/
function findCodeRabbitCommand(): string | null {
const commands = ['coderabbit', 'cr'];
for (const command of commands) {
try {
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
const result = execSync(`${whichCommand} ${command}`, {
encoding: 'utf8',
timeout: 2000,
}).trim();
if (result) {
return result.split('\n')[0];
}
} catch {
// Command not found, try next
}
}
return null;
}
/**
* Get CodeRabbit CLI version
*/
async function getCodeRabbitVersion(command: string): Promise<string | null> {
return new Promise((resolve) => {
const child = spawn(command, ['--version'], {
stdio: 'pipe',
timeout: 5000,
});
let stdout = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.on('close', (code) => {
if (code === 0 && stdout) {
resolve(stdout.trim());
} else {
resolve(null);
}
});
child.on('error', () => {
resolve(null);
});
});
}
interface CodeRabbitAuthInfo {
authenticated: boolean;
method: 'oauth' | 'none';
username?: string;
email?: string;
organization?: string;
}
/**
* Check CodeRabbit CLI authentication status
* Parses output like:
* ```
* CodeRabbit CLI Status
* ✅ Authentication: Logged in
* User Information:
* 👤 Name: Kacper
* 📧 Email: kacperlachowiczwp.pl@wp.pl
* 🔧 Username: Shironex
* Organization Information:
* 🏢 Name: Anime-World-SPZOO
* ```
*/
async function getCodeRabbitAuthStatus(command: string): Promise<CodeRabbitAuthInfo> {
return new Promise((resolve) => {
const child = spawn(command, ['auth', 'status'], {
stdio: 'pipe',
timeout: 10000,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
const output = stdout + stderr;
// Check for "Logged in" in Authentication line
const isAuthenticated =
code === 0 &&
(output.includes('Logged in') || output.includes('logged in')) &&
!output.toLowerCase().includes('not logged in');
if (isAuthenticated) {
// Parse the structured output format
// Username: look for "Username: <value>" line
const usernameMatch = output.match(/Username:\s*(\S+)/i);
// Email: look for "Email: <value>" line
const emailMatch = output.match(/Email:\s*(\S+@\S+)/i);
// Organization: look for "Name: <value>" under Organization Information
// The org name appears after "Organization Information:" section
const orgSection = output.split(/Organization Information:/i)[1];
const orgMatch = orgSection?.match(/Name:\s*(.+?)(?:\n|$)/i);
resolve({
authenticated: true,
method: 'oauth',
username: usernameMatch?.[1]?.trim(),
email: emailMatch?.[1]?.trim(),
organization: orgMatch?.[1]?.trim(),
});
} else {
resolve({
authenticated: false,
method: 'none',
});
}
});
child.on('error', () => {
resolve({
authenticated: false,
method: 'none',
});
});
});
}
/**
* Creates handler for GET /api/setup/coderabbit-status
* Returns CodeRabbit CLI installation and authentication status
*/
export function createCodeRabbitStatusHandler() {
const installCommand = 'npm install -g coderabbit';
const loginCommand = 'coderabbit auth login';
return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (isCodeRabbitDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
},
recommendation: 'CodeRabbit CLI is disconnected. Click Sign In to reconnect.',
installCommand,
loginCommand,
});
return;
}
// Find CodeRabbit CLI
const cliPath = findCodeRabbitCommand();
if (!cliPath) {
res.json({
success: true,
installed: false,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
},
recommendation: 'Install CodeRabbit CLI to enable AI-powered code reviews.',
installCommand,
loginCommand,
installCommands: {
macos: 'curl -fsSL https://coderabbit.ai/install | bash',
npm: installCommand,
},
});
return;
}
// Get version
const version = await getCodeRabbitVersion(cliPath);
// Get auth status
const authStatus = await getCodeRabbitAuthStatus(cliPath);
res.json({
success: true,
installed: true,
version,
path: cliPath,
auth: authStatus,
recommendation: authStatus.authenticated
? undefined
: 'Sign in to CodeRabbit to enable AI-powered code reviews.',
installCommand,
loginCommand,
installCommands: {
macos: 'curl -fsSL https://coderabbit.ai/install | bash',
npm: installCommand,
},
});
} catch (error) {
logError(error, 'Get CodeRabbit status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,113 @@
/**
* POST /deauth-coderabbit endpoint - Sign out from CodeRabbit CLI
*/
import type { Request, Response } from 'express';
import { spawn, execSync } from 'child_process';
import { logError, getErrorMessage } from '../common.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Find the CodeRabbit CLI command (coderabbit or cr)
*/
function findCodeRabbitCommand(): string | null {
const commands = ['coderabbit', 'cr'];
for (const command of commands) {
try {
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
const result = execSync(`${whichCommand} ${command}`, {
encoding: 'utf8',
timeout: 2000,
}).trim();
if (result) {
return result.split('\n')[0];
}
} catch {
// Command not found, try next
}
}
return null;
}
export function createDeauthCodeRabbitHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Find CodeRabbit CLI
const cliPath = findCodeRabbitCommand();
if (cliPath) {
// Try to run the CLI logout command
const logoutResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
const child = spawn(cliPath, ['auth', 'logout'], {
stdio: 'pipe',
timeout: 10000,
});
let stderr = '';
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve({ success: true });
} else {
resolve({ success: false, error: stderr || 'Logout command failed' });
}
});
child.on('error', (err) => {
resolve({ success: false, error: err.message });
});
});
if (!logoutResult.success) {
// CLI logout failed, create marker file as fallback
const automakerDir = path.join(process.cwd(), '.automaker');
const markerPath = path.join(automakerDir, '.coderabbit-disconnected');
if (!fs.existsSync(automakerDir)) {
fs.mkdirSync(automakerDir, { recursive: true });
}
fs.writeFileSync(
markerPath,
JSON.stringify({
disconnectedAt: new Date().toISOString(),
message: 'CodeRabbit CLI is disconnected from the app',
})
);
}
} else {
// CLI not installed, just create marker file
const automakerDir = path.join(process.cwd(), '.automaker');
const markerPath = path.join(automakerDir, '.coderabbit-disconnected');
if (!fs.existsSync(automakerDir)) {
fs.mkdirSync(automakerDir, { recursive: true });
}
fs.writeFileSync(
markerPath,
JSON.stringify({
disconnectedAt: new Date().toISOString(),
message: 'CodeRabbit CLI is disconnected from the app',
})
);
}
res.json({
success: true,
message: 'Successfully signed out from CodeRabbit CLI',
});
} catch (error) {
logError(error, 'Deauth CodeRabbit failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
message: 'Failed to sign out from CodeRabbit CLI',
});
}
};
}

View File

@@ -0,0 +1,249 @@
/**
* GET /status endpoint - Get unified CLI availability status
*
* Returns the installation and authentication status of all supported CLIs
* in a single response. This is useful for quickly determining which
* providers are available without making multiple API calls.
*/
import type { Request, Response } from 'express';
import { getClaudeStatus } from '../get-claude-status.js';
import { getErrorMessage, logError } from '../common.js';
import { CursorProvider } from '../../../providers/cursor-provider.js';
import { CodexProvider } from '../../../providers/codex-provider.js';
import { OpencodeProvider } from '../../../providers/opencode-provider.js';
import * as fs from 'fs';
import * as path from 'path';
/**
* Check if a CLI has been manually disconnected from the app
*/
function isCliDisconnected(cliName: string): boolean {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', `.${cliName}-disconnected`);
return fs.existsSync(markerPath);
} catch {
return false;
}
}
/**
* CLI status response for a single provider
*/
interface CliStatusResponse {
installed: boolean;
version: string | null;
path: string | null;
auth: {
authenticated: boolean;
method: string;
};
disconnected: boolean;
}
/**
* Unified status response for all CLIs
*/
interface UnifiedStatusResponse {
success: boolean;
timestamp: string;
clis: {
claude: CliStatusResponse | null;
cursor: CliStatusResponse | null;
codex: CliStatusResponse | null;
opencode: CliStatusResponse | null;
};
availableProviders: string[];
hasAnyAuthenticated: boolean;
}
/**
* Get detailed Claude CLI status
*/
async function getClaudeCliStatus(): Promise<CliStatusResponse> {
const disconnected = isCliDisconnected('claude');
try {
const status = await getClaudeStatus();
return {
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: disconnected ? false : status.auth.authenticated,
method: disconnected ? 'none' : status.auth.method,
},
disconnected,
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected,
};
}
}
/**
* Get detailed Cursor CLI status
*/
async function getCursorCliStatus(): Promise<CliStatusResponse> {
const disconnected = isCliDisconnected('cursor');
try {
const provider = new CursorProvider();
const [installed, version, auth] = await Promise.all([
provider.isInstalled(),
provider.getVersion(),
provider.checkAuth(),
]);
const cliPath = installed ? provider.getCliPath() : null;
return {
installed,
version: version || null,
path: cliPath,
auth: {
authenticated: disconnected ? false : auth.authenticated,
method: disconnected ? 'none' : auth.method,
},
disconnected,
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected,
};
}
}
/**
* Get detailed Codex CLI status
*/
async function getCodexCliStatus(): Promise<CliStatusResponse> {
const disconnected = isCliDisconnected('codex');
try {
const provider = new CodexProvider();
const status = await provider.detectInstallation();
let authMethod = 'none';
if (!disconnected && status.authenticated) {
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
}
return {
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: disconnected ? false : status.authenticated || false,
method: authMethod,
},
disconnected,
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected,
};
}
}
/**
* Get detailed OpenCode CLI status
*/
async function getOpencodeCliStatus(): Promise<CliStatusResponse> {
try {
const provider = new OpencodeProvider();
const status = await provider.detectInstallation();
let authMethod = 'none';
if (status.authenticated) {
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
}
return {
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: status.authenticated || false,
method: authMethod,
},
disconnected: false, // OpenCode doesn't have disconnect feature
};
} catch {
return {
installed: false,
version: null,
path: null,
auth: { authenticated: false, method: 'none' },
disconnected: false,
};
}
}
/**
* Creates handler for GET /api/setup/status
* Returns unified CLI availability status for all providers
*/
export function createStatusHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
// Fetch all CLI statuses in parallel for performance
const [claude, cursor, codex, opencode] = await Promise.all([
getClaudeCliStatus(),
getCursorCliStatus(),
getCodexCliStatus(),
getOpencodeCliStatus(),
]);
// Determine which providers are available (installed and authenticated)
const availableProviders: string[] = [];
if (claude.installed && claude.auth.authenticated) {
availableProviders.push('claude');
}
if (cursor.installed && cursor.auth.authenticated) {
availableProviders.push('cursor');
}
if (codex.installed && codex.auth.authenticated) {
availableProviders.push('codex');
}
if (opencode.installed && opencode.auth.authenticated) {
availableProviders.push('opencode');
}
const response: UnifiedStatusResponse = {
success: true,
timestamp: new Date().toISOString(),
clis: {
claude,
cursor,
codex,
opencode,
},
availableProviders,
hasAnyAuthenticated: availableProviders.length > 0,
};
res.json(response);
} catch (error) {
logError(error, 'Get unified CLI status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}

View File

@@ -0,0 +1,163 @@
/**
* POST /verify-coderabbit-auth endpoint - Verify CodeRabbit authentication
* Validates API key format and optionally tests the connection
*/
import type { Request, Response } from 'express';
import { spawn } from 'child_process';
import { createLogger } from '@automaker/utils';
import { AuthRateLimiter, validateApiKey } from '../../../lib/auth-utils.js';
const logger = createLogger('Setup');
const rateLimiter = new AuthRateLimiter();
/**
* Test CodeRabbit CLI authentication by running a simple command
*/
async function testCodeRabbitCli(
apiKey?: string
): Promise<{ authenticated: boolean; error?: string }> {
return new Promise((resolve) => {
// Set up environment with API key if provided
const env = { ...process.env };
if (apiKey) {
env.CODERABBIT_API_KEY = apiKey;
}
// Try to run coderabbit auth status to verify auth
const child = spawn('coderabbit', ['auth', 'status'], {
stdio: ['pipe', 'pipe', 'pipe'],
env,
timeout: 10000,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
// Check output for authentication status
const output = stdout.toLowerCase() + stderr.toLowerCase();
if (
output.includes('authenticated') ||
output.includes('logged in') ||
output.includes('valid')
) {
resolve({ authenticated: true });
} else if (output.includes('not authenticated') || output.includes('not logged in')) {
resolve({ authenticated: false, error: 'CodeRabbit CLI is not authenticated.' });
} else {
// Command succeeded, assume authenticated
resolve({ authenticated: true });
}
} else {
// Command failed
const errorMsg = stderr || stdout || 'CodeRabbit CLI authentication check failed.';
resolve({ authenticated: false, error: errorMsg.trim() });
}
});
child.on('error', (err) => {
// CodeRabbit CLI not installed or other error
resolve({ authenticated: false, error: `CodeRabbit CLI error: ${err.message}` });
});
});
}
/**
* Validate CodeRabbit API key format
* CodeRabbit API keys typically start with 'cr-'
*/
function validateCodeRabbitKey(apiKey: string): { isValid: boolean; error?: string } {
if (!apiKey || apiKey.trim().length === 0) {
return { isValid: false, error: 'API key cannot be empty.' };
}
// CodeRabbit API keys typically start with 'cr-'
if (!apiKey.startsWith('cr-')) {
return {
isValid: false,
error: 'Invalid CodeRabbit API key format. Keys should start with "cr-".',
};
}
if (apiKey.length < 10) {
return { isValid: false, error: 'API key is too short.' };
}
return { isValid: true };
}
export function createVerifyCodeRabbitAuthHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { authMethod, apiKey } = req.body as {
authMethod?: 'cli' | 'api_key';
apiKey?: string;
};
// Rate limiting to prevent abuse
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
if (!rateLimiter.canAttempt(clientIp)) {
const resetTime = rateLimiter.getResetTime(clientIp);
res.status(429).json({
success: false,
authenticated: false,
error: 'Too many authentication attempts. Please try again later.',
resetTime,
});
return;
}
logger.info(
`[Setup] Verifying CodeRabbit authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
);
// For API key verification
if (authMethod === 'api_key' && apiKey) {
// Validate key format
const validation = validateCodeRabbitKey(apiKey);
if (!validation.isValid) {
res.json({
success: true,
authenticated: false,
error: validation.error,
});
return;
}
// Test the CLI with the provided API key
const result = await testCodeRabbitCli(apiKey);
res.json({
success: true,
authenticated: result.authenticated,
error: result.error,
});
return;
}
// For CLI auth or auto detection
const result = await testCodeRabbitCli();
res.json({
success: true,
authenticated: result.authenticated,
error: result.error,
});
} catch (error) {
logger.error('[Setup] Verify CodeRabbit auth endpoint error:', error);
res.status(500).json({
success: false,
authenticated: false,
error: error instanceof Error ? error.message : 'Verification failed',
});
}
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -420,6 +420,7 @@ export class SettingsService {
anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string };
coderabbit: { configured: boolean; masked: string };
}> {
const credentials = await this.getCredentials();
@@ -441,6 +442,10 @@ export class SettingsService {
configured: !!credentials.apiKeys.openai,
masked: maskKey(credentials.apiKeys.openai),
},
coderabbit: {
configured: !!credentials.apiKeys.coderabbit,
masked: maskKey(credentials.apiKeys.coderabbit),
},
};
}
@@ -658,12 +663,14 @@ export class SettingsService {
anthropic?: string;
google?: string;
openai?: string;
coderabbit?: string;
};
await this.updateCredentials({
apiKeys: {
anthropic: apiKeys.anthropic || '',
google: apiKeys.google || '',
openai: apiKeys.openai || '',
coderabbit: apiKeys.coderabbit || '',
},
});
migratedCredentials = true;

View File

@@ -0,0 +1,196 @@
/**
* Unit tests for code-review providers route handler
*
* Tests:
* - Returns provider status list
* - Returns recommended provider
* - Force refresh functionality
* - Error handling
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createProvidersHandler } from '@/routes/code-review/routes/providers.js';
import type { CodeReviewService } from '@/services/code-review-service.js';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock logger
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('code-review/providers route', () => {
let mockCodeReviewService: CodeReviewService;
let req: Request;
let res: Response;
const mockProviderStatuses = [
{
provider: 'claude' as const,
available: true,
authenticated: true,
version: '1.0.0',
issues: [],
},
{
provider: 'codex' as const,
available: true,
authenticated: false,
version: '0.5.0',
issues: ['Not authenticated'],
},
];
beforeEach(() => {
vi.clearAllMocks();
mockCodeReviewService = {
getProviderStatus: vi.fn().mockResolvedValue(mockProviderStatuses),
getBestProvider: vi.fn().mockResolvedValue('claude'),
executeReview: vi.fn(),
refreshProviderStatus: vi.fn(),
initialize: vi.fn(),
} as any;
const context = createMockExpressContext();
req = context.req;
res = context.res;
req.query = {};
});
describe('successful responses', () => {
it('should return provider status and recommended provider', async () => {
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
providers: mockProviderStatuses,
recommended: 'claude',
});
});
it('should use cached status by default', async () => {
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(mockCodeReviewService.getProviderStatus).toHaveBeenCalledWith(false);
});
it('should force refresh when refresh=true query param is set', async () => {
req.query = { refresh: 'true' };
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(mockCodeReviewService.getProviderStatus).toHaveBeenCalledWith(true);
});
it('should handle no recommended provider', async () => {
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue(null);
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
providers: mockProviderStatuses,
recommended: null,
});
});
it('should handle empty provider list', async () => {
mockCodeReviewService.getProviderStatus = vi.fn().mockResolvedValue([]);
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue(null);
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
providers: [],
recommended: null,
});
});
});
describe('error handling', () => {
it('should handle getProviderStatus errors', async () => {
mockCodeReviewService.getProviderStatus = vi
.fn()
.mockRejectedValue(new Error('Failed to detect CLIs'));
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Failed to detect CLIs',
});
});
it('should handle getBestProvider errors gracefully', async () => {
mockCodeReviewService.getBestProvider = vi
.fn()
.mockRejectedValue(new Error('Detection failed'));
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Detection failed',
});
});
});
describe('provider priority', () => {
it('should recommend claude when available and authenticated', async () => {
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
recommended: 'claude',
})
);
});
it('should recommend codex when claude is not available', async () => {
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue('codex');
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
recommended: 'codex',
})
);
});
it('should recommend cursor as fallback', async () => {
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue('cursor');
const handler = createProvidersHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
recommended: 'cursor',
})
);
});
});
});

View File

@@ -0,0 +1,109 @@
/**
* Unit tests for code-review status route handler
*
* Tests:
* - Returns correct running status
* - Returns correct project path
* - Handles errors gracefully
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createStatusHandler } from '@/routes/code-review/routes/status.js';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock the common module to control running state
vi.mock('@/routes/code-review/common.js', () => {
return {
isRunning: vi.fn(),
getReviewStatus: vi.fn(),
getCurrentProjectPath: vi.fn(),
setRunningState: vi.fn(),
getAbortController: vi.fn(),
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
logError: vi.fn(),
};
});
// Mock logger
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('code-review/status route', () => {
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('when no review is running', () => {
it('should return isRunning: false with null projectPath', async () => {
const { getReviewStatus } = await import('@/routes/code-review/common.js');
vi.mocked(getReviewStatus).mockReturnValue({
isRunning: false,
projectPath: null,
});
const handler = createStatusHandler();
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
isRunning: false,
projectPath: null,
});
});
});
describe('when a review is running', () => {
it('should return isRunning: true with the current project path', async () => {
const { getReviewStatus } = await import('@/routes/code-review/common.js');
vi.mocked(getReviewStatus).mockReturnValue({
isRunning: true,
projectPath: '/test/project',
});
const handler = createStatusHandler();
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
isRunning: true,
projectPath: '/test/project',
});
});
});
describe('error handling', () => {
it('should handle errors gracefully', async () => {
const { getReviewStatus } = await import('@/routes/code-review/common.js');
vi.mocked(getReviewStatus).mockImplementation(() => {
throw new Error('Unexpected error');
});
const handler = createStatusHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Unexpected error',
});
});
});
});

View File

@@ -0,0 +1,129 @@
/**
* Unit tests for code-review stop route handler
*
* Tests:
* - Stopping when no review is running
* - Stopping a running review
* - Abort controller behavior
* - Error handling
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createStopHandler } from '@/routes/code-review/routes/stop.js';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock the common module
vi.mock('@/routes/code-review/common.js', () => {
return {
isRunning: vi.fn(),
getAbortController: vi.fn(),
setRunningState: vi.fn(),
getReviewStatus: vi.fn(),
getCurrentProjectPath: vi.fn(),
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
logError: vi.fn(),
};
});
// Mock logger
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('code-review/stop route', () => {
let req: Request;
let res: Response;
beforeEach(() => {
vi.clearAllMocks();
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('when no review is running', () => {
it('should return success with message that nothing is running', async () => {
const { isRunning } = await import('@/routes/code-review/common.js');
vi.mocked(isRunning).mockReturnValue(false);
const handler = createStopHandler();
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'No code review is currently running',
});
});
});
describe('when a review is running', () => {
it('should abort the review and reset running state', async () => {
const { isRunning, getAbortController, setRunningState } =
await import('@/routes/code-review/common.js');
const mockAbortController = {
abort: vi.fn(),
signal: { aborted: false },
};
vi.mocked(isRunning).mockReturnValue(true);
vi.mocked(getAbortController).mockReturnValue(mockAbortController as any);
const handler = createStopHandler();
await handler(req, res);
expect(mockAbortController.abort).toHaveBeenCalled();
expect(setRunningState).toHaveBeenCalledWith(false, null, null);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review stopped',
});
});
it('should handle case when abort controller is null', async () => {
const { isRunning, getAbortController, setRunningState } =
await import('@/routes/code-review/common.js');
vi.mocked(isRunning).mockReturnValue(true);
vi.mocked(getAbortController).mockReturnValue(null);
const handler = createStopHandler();
await handler(req, res);
expect(setRunningState).toHaveBeenCalledWith(false, null, null);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review stopped',
});
});
});
describe('error handling', () => {
it('should handle errors gracefully', async () => {
const { isRunning } = await import('@/routes/code-review/common.js');
vi.mocked(isRunning).mockImplementation(() => {
throw new Error('Unexpected error');
});
const handler = createStopHandler();
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Unexpected error',
});
});
});
});

View File

@@ -0,0 +1,384 @@
/**
* Unit tests for code-review trigger route handler
*
* Tests:
* - Parameter validation
* - Request body validation (security)
* - Concurrent review prevention
* - Review execution
* - Error handling
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response } from 'express';
import { createTriggerHandler } from '@/routes/code-review/routes/trigger.js';
import type { CodeReviewService } from '@/services/code-review-service.js';
import { createMockExpressContext } from '../../../utils/mocks.js';
// Mock the common module to control running state
vi.mock('@/routes/code-review/common.js', () => {
let running = false;
return {
isRunning: vi.fn(() => running),
setRunningState: vi.fn((state: boolean) => {
running = state;
}),
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
logError: vi.fn(),
getAbortController: vi.fn(() => null),
getCurrentProjectPath: vi.fn(() => null),
};
});
// Mock logger
vi.mock('@automaker/utils', async () => {
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
return {
...actual,
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
};
});
describe('code-review/trigger route', () => {
let mockCodeReviewService: CodeReviewService;
let req: Request;
let res: Response;
beforeEach(async () => {
vi.clearAllMocks();
// Reset running state
const { setRunningState, isRunning } = await import('@/routes/code-review/common.js');
vi.mocked(setRunningState)(false);
vi.mocked(isRunning).mockReturnValue(false);
mockCodeReviewService = {
executeReview: vi.fn().mockResolvedValue({
id: 'review-123',
verdict: 'approved',
summary: 'No issues found',
comments: [],
stats: {
totalComments: 0,
bySeverity: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
byCategory: {},
autoFixedCount: 0,
},
filesReviewed: ['src/index.ts'],
model: 'claude-sonnet-4-20250514',
reviewedAt: new Date().toISOString(),
durationMs: 1000,
}),
getProviderStatus: vi.fn(),
getBestProvider: vi.fn(),
refreshProviderStatus: vi.fn(),
initialize: vi.fn(),
} as any;
const context = createMockExpressContext();
req = context.req;
res = context.res;
});
describe('parameter validation', () => {
it('should return 400 if projectPath is missing', async () => {
req.body = {};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'projectPath is required',
});
expect(mockCodeReviewService.executeReview).not.toHaveBeenCalled();
});
it('should return 400 if files is not an array', async () => {
req.body = {
projectPath: '/test/project',
files: 'not-an-array',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'files must be an array',
});
});
it('should return 400 if too many files', async () => {
req.body = {
projectPath: '/test/project',
files: Array.from({ length: 150 }, (_, i) => `file${i}.ts`),
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Maximum 100 files allowed per request',
});
});
it('should return 400 if file path is too long', async () => {
req.body = {
projectPath: '/test/project',
files: ['a'.repeat(600)],
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'File path too long',
});
});
it('should return 400 if baseRef is not a string', async () => {
req.body = {
projectPath: '/test/project',
baseRef: 123,
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'baseRef must be a string',
});
});
it('should return 400 if baseRef is too long', async () => {
req.body = {
projectPath: '/test/project',
baseRef: 'a'.repeat(300),
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'baseRef is too long',
});
});
it('should return 400 if categories is not an array', async () => {
req.body = {
projectPath: '/test/project',
categories: 'security',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'categories must be an array',
});
});
it('should return 400 if category is invalid', async () => {
req.body = {
projectPath: '/test/project',
categories: ['security', 'invalid_category'],
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Invalid category: invalid_category',
});
});
it('should return 400 if autoFix is not a boolean', async () => {
req.body = {
projectPath: '/test/project',
autoFix: 'true',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'autoFix must be a boolean',
});
});
it('should return 400 if thinkingLevel is invalid', async () => {
req.body = {
projectPath: '/test/project',
thinkingLevel: 'invalid',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'Invalid thinkingLevel: invalid',
});
});
});
describe('concurrent review prevention', () => {
it('should return 409 if a review is already in progress', async () => {
const { isRunning } = await import('@/routes/code-review/common.js');
vi.mocked(isRunning).mockReturnValue(true);
req.body = { projectPath: '/test/project' };
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: 'A code review is already in progress',
});
expect(mockCodeReviewService.executeReview).not.toHaveBeenCalled();
});
});
describe('successful review execution', () => {
it('should trigger review and return success immediately', async () => {
req.body = {
projectPath: '/test/project',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review started',
});
});
it('should pass all options to executeReview', async () => {
req.body = {
projectPath: '/test/project',
files: ['src/index.ts', 'src/utils.ts'],
baseRef: 'main',
categories: ['security', 'performance'],
autoFix: true,
model: 'claude-opus-4-5-20251101',
thinkingLevel: 'high',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
// Wait for async execution
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockCodeReviewService.executeReview).toHaveBeenCalledWith(
expect.objectContaining({
projectPath: '/test/project',
files: ['src/index.ts', 'src/utils.ts'],
baseRef: 'main',
categories: ['security', 'performance'],
autoFix: true,
model: 'claude-opus-4-5-20251101',
thinkingLevel: 'high',
abortController: expect.any(AbortController),
})
);
});
it('should accept valid categories', async () => {
const validCategories = [
'tech_stack',
'security',
'code_quality',
'implementation',
'architecture',
'performance',
'testing',
'documentation',
];
req.body = {
projectPath: '/test/project',
categories: validCategories,
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review started',
});
});
it('should accept valid thinking levels', async () => {
for (const level of ['low', 'medium', 'high']) {
req.body = {
projectPath: '/test/project',
thinkingLevel: level,
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review started',
});
vi.clearAllMocks();
}
});
});
describe('error handling', () => {
it('should handle service errors gracefully', async () => {
mockCodeReviewService.executeReview = vi.fn().mockRejectedValue(new Error('Service error'));
req.body = {
projectPath: '/test/project',
};
const handler = createTriggerHandler(mockCodeReviewService);
await handler(req, res);
// Response is sent immediately (async execution)
expect(res.json).toHaveBeenCalledWith({
success: true,
message: 'Code review started',
});
// Wait for async error handling
await new Promise((resolve) => setTimeout(resolve, 50));
// Running state should be reset
const { setRunningState } = await import('@/routes/code-review/common.js');
expect(setRunningState).toHaveBeenCalledWith(false);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,755 @@
/**
* CodeReviewDialog Component
*
* A dialog for displaying code review results from automated code analysis.
* Shows the review verdict, summary, and detailed comments organized by severity.
*/
import { useMemo, useState } from 'react';
import {
CheckCircle2,
AlertTriangle,
MessageSquare,
FileCode,
Copy,
Check,
AlertCircle,
Info,
ChevronDown,
ChevronRight,
Sparkles,
Clock,
Wrench,
RotateCcw,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { cn } from '@/lib/utils';
import type {
CodeReviewResult,
CodeReviewComment,
CodeReviewSeverity,
CodeReviewCategory,
CodeReviewVerdict,
} from '@automaker/types';
// ============================================================================
// Types
// ============================================================================
export interface CodeReviewDialogProps {
/** Whether the dialog is open */
open: boolean;
/** Callback when open state changes */
onOpenChange: (open: boolean) => void;
/** The code review result to display */
review: CodeReviewResult | null;
/** Optional loading state */
loading?: boolean;
/** Optional error message */
error?: string | null;
/** Optional callback when user wants to retry */
onRetry?: () => void;
}
// ============================================================================
// Constants & Helpers
// ============================================================================
const SEVERITY_CONFIG: Record<
CodeReviewSeverity,
{ label: string; variant: 'error' | 'warning' | 'info' | 'muted'; icon: typeof AlertCircle }
> = {
critical: { label: 'Critical', variant: 'error', icon: AlertCircle },
high: { label: 'High', variant: 'error', icon: AlertTriangle },
medium: { label: 'Medium', variant: 'warning', icon: AlertTriangle },
low: { label: 'Low', variant: 'info', icon: Info },
info: { label: 'Info', variant: 'muted', icon: Info },
};
const SEVERITY_ORDER: CodeReviewSeverity[] = ['critical', 'high', 'medium', 'low', 'info'];
const CATEGORY_LABELS: Record<CodeReviewCategory, string> = {
tech_stack: 'Tech Stack',
security: 'Security',
code_quality: 'Code Quality',
implementation: 'Implementation',
architecture: 'Architecture',
performance: 'Performance',
testing: 'Testing',
documentation: 'Documentation',
};
const VERDICT_CONFIG: Record<
CodeReviewVerdict,
{ label: string; variant: 'success' | 'warning' | 'info'; icon: typeof CheckCircle2 }
> = {
approved: { label: 'Approved', variant: 'success', icon: CheckCircle2 },
changes_requested: { label: 'Changes Requested', variant: 'warning', icon: AlertTriangle },
needs_discussion: { label: 'Needs Discussion', variant: 'info', icon: MessageSquare },
};
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
// ============================================================================
// Sub-components
// ============================================================================
interface VerdictBadgeProps {
verdict: CodeReviewVerdict;
className?: string;
}
function VerdictBadge({ verdict, className }: VerdictBadgeProps) {
const config = VERDICT_CONFIG[verdict];
const Icon = config.icon;
return (
<Badge variant={config.variant} size="lg" className={cn('gap-1.5', className)}>
<Icon className="w-3.5 h-3.5" />
{config.label}
</Badge>
);
}
interface SeverityBadgeProps {
severity: CodeReviewSeverity;
count?: number;
className?: string;
}
function SeverityBadge({ severity, count, className }: SeverityBadgeProps) {
const config = SEVERITY_CONFIG[severity];
const Icon = config.icon;
return (
<Badge variant={config.variant} size="sm" className={cn('gap-1', className)}>
<Icon className="w-3 h-3" />
{config.label}
{count !== undefined && count > 0 && <span className="ml-0.5">({count})</span>}
</Badge>
);
}
interface CategoryBadgeProps {
category: CodeReviewCategory;
className?: string;
}
function CategoryBadge({ category, className }: CategoryBadgeProps) {
return (
<Badge variant="outline" size="sm" className={className}>
{CATEGORY_LABELS[category]}
</Badge>
);
}
interface CommentCardProps {
comment: CodeReviewComment;
defaultExpanded?: boolean;
}
function CommentCard({ comment, defaultExpanded = false }: CommentCardProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [copied, setCopied] = useState(false);
const commentId = `comment-${comment.id}`;
const lineRange =
comment.startLine === comment.endLine
? `Line ${comment.startLine}`
: `Lines ${comment.startLine}-${comment.endLine}`;
const handleCopyCode = async () => {
if (comment.suggestedCode) {
await navigator.clipboard.writeText(comment.suggestedCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div
className={cn(
'rounded-lg border border-border bg-card/50 overflow-hidden transition-all duration-200',
'hover:border-border/80',
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2'
)}
>
{/* Header - accessible expand/collapse button */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full flex items-start gap-3 p-3 text-left hover:bg-accent/30 transition-colors focus:outline-none focus-visible:bg-accent/30"
aria-expanded={expanded}
aria-controls={commentId}
aria-label={`${expanded ? 'Collapse' : 'Expand'} comment for ${comment.filePath} at ${lineRange}`}
>
<div className="flex-shrink-0 mt-0.5" aria-hidden="true">
{expanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
{/* File and line info */}
<div className="flex items-center gap-2 flex-wrap mb-1.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<FileCode className="w-3.5 h-3.5" aria-hidden="true" />
<span className="font-mono truncate max-w-[200px]" title={comment.filePath}>
{comment.filePath}
</span>
<span className="text-muted-foreground/60" aria-hidden="true">
:
</span>
<span>{lineRange}</span>
</div>
</div>
{/* Comment preview */}
<p className={cn('text-sm text-foreground', !expanded && 'line-clamp-2')}>
{comment.body}
</p>
{/* Visual indicator for truncated content */}
{!expanded && comment.body.length > 150 && (
<span className="text-xs text-muted-foreground/60 mt-1 inline-block">
Click to expand...
</span>
)}
</div>
{/* Badges */}
<div className="flex items-center gap-1.5 flex-shrink-0">
<SeverityBadge severity={comment.severity} />
<CategoryBadge category={comment.category} />
{comment.autoFixed && (
<Badge variant="success" size="sm" className="gap-1">
<Wrench className="w-3 h-3" aria-hidden="true" />
<span>Fixed</span>
</Badge>
)}
</div>
</button>
{/* Expanded content */}
{expanded && (
<div
id={commentId}
className="px-3 pb-3 pt-0 space-y-3 border-t border-border/50"
role="region"
aria-label={`Details for comment on ${comment.filePath}`}
>
{/* Full body */}
<div className="pl-7 pt-3">
<p className="text-sm text-foreground whitespace-pre-wrap">{comment.body}</p>
</div>
{/* Suggested fix */}
{comment.suggestedFix && (
<div className="pl-7">
<div className="rounded-md bg-muted/50 p-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-2">
<Sparkles className="w-3.5 h-3.5" aria-hidden="true" />
<span>Suggested Fix</span>
</div>
<p className="text-sm text-foreground">{comment.suggestedFix}</p>
</div>
</div>
)}
{/* Suggested code */}
{comment.suggestedCode && (
<div className="pl-7">
<div className="rounded-md bg-muted/80 overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted border-b border-border/50">
<span className="text-xs font-medium text-muted-foreground">Suggested Code</span>
<Button
variant="ghost"
size="sm"
onClick={handleCopyCode}
className="h-6 px-2 text-xs focus-visible:ring-2 focus-visible:ring-ring"
aria-label={copied ? 'Code copied to clipboard' : 'Copy code to clipboard'}
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" aria-hidden="true" />
<span>Copied</span>
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" aria-hidden="true" />
<span>Copy</span>
</>
)}
</Button>
</div>
<pre className="p-3 overflow-x-auto text-xs font-mono text-foreground" tabIndex={0}>
<code>{comment.suggestedCode}</code>
</pre>
</div>
</div>
)}
</div>
)}
</div>
);
}
interface StatsOverviewProps {
review: CodeReviewResult;
}
function StatsOverview({ review }: StatsOverviewProps) {
const { stats } = review;
return (
<div className="flex flex-wrap gap-2">
{SEVERITY_ORDER.map((severity) => {
const count = stats.bySeverity[severity] || 0;
if (count === 0) return null;
return <SeverityBadge key={severity} severity={severity} count={count} />;
})}
{stats.autoFixedCount > 0 && (
<Badge variant="success" size="sm" className="gap-1">
<Wrench className="w-3 h-3" />
{stats.autoFixedCount} auto-fixed
</Badge>
)}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function CodeReviewDialog({
open,
onOpenChange,
review,
loading = false,
error = null,
onRetry,
}: CodeReviewDialogProps) {
const [activeTab, setActiveTab] = useState<'severity' | 'file'>('severity');
// Group comments by severity
const commentsBySeverity = useMemo(() => {
if (!review) return {};
const grouped: Partial<Record<CodeReviewSeverity, CodeReviewComment[]>> = {};
for (const comment of review.comments) {
if (!grouped[comment.severity]) {
grouped[comment.severity] = [];
}
grouped[comment.severity]!.push(comment);
}
return grouped;
}, [review]);
// Group comments by file
const commentsByFile = useMemo(() => {
if (!review) return {};
const grouped: Record<string, CodeReviewComment[]> = {};
for (const comment of review.comments) {
if (!grouped[comment.filePath]) {
grouped[comment.filePath] = [];
}
grouped[comment.filePath].push(comment);
}
// Sort comments within each file by line number
Object.values(grouped).forEach((comments) => {
comments.sort((a, b) => a.startLine - b.startLine);
});
return grouped;
}, [review]);
// Render loading state with improved skeleton and progress
if (loading) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-3xl"
data-testid="code-review-dialog"
aria-busy="true"
aria-describedby="loading-description"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileCode className="w-5 h-5 text-brand-500" aria-hidden="true" />
Code Review
</DialogTitle>
<DialogDescription id="loading-description">
Analyzing your code for best practices, security, and performance issues...
</DialogDescription>
</DialogHeader>
{/* Loading skeleton with spinner and placeholders */}
<div className="space-y-4 py-4">
{/* Spinner and status */}
<div className="flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div
className="animate-spin rounded-full h-10 w-10 border-3 border-primary border-t-transparent"
role="progressbar"
aria-label="Code review in progress"
/>
<p className="text-sm text-muted-foreground font-medium">Running code review...</p>
</div>
</div>
{/* Skeleton placeholders for expected content */}
<div className="space-y-3 animate-pulse">
{/* Verdict skeleton */}
<div className="flex items-center justify-between">
<div className="h-4 w-32 bg-muted rounded" />
<div className="h-6 w-24 bg-muted rounded-full" />
</div>
{/* Summary skeleton */}
<div className="space-y-2 py-3 border-y border-border/50">
<div className="h-4 w-full bg-muted rounded" />
<div className="h-4 w-3/4 bg-muted rounded" />
<div className="flex gap-2 mt-2">
<div className="h-5 w-16 bg-muted rounded-full" />
<div className="h-5 w-16 bg-muted rounded-full" />
<div className="h-5 w-16 bg-muted rounded-full" />
</div>
</div>
{/* Comments skeleton */}
<div className="space-y-2">
<div className="h-16 w-full bg-muted/50 rounded-lg" />
<div className="h-16 w-full bg-muted/50 rounded-lg" />
</div>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
data-testid="code-review-loading-close"
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Render error state with improved accessibility
if (error) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-md"
data-testid="code-review-dialog"
role="alertdialog"
aria-describedby="error-description"
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="w-5 h-5" aria-hidden="true" />
Review Failed
</DialogTitle>
<DialogDescription id="error-description">
Something went wrong during the code review.
</DialogDescription>
</DialogHeader>
<div className="py-4" role="alert" aria-live="polite">
<p className="text-sm text-destructive bg-destructive/10 rounded-md p-3 border border-destructive/20">
{error}
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
data-testid="code-review-error-close"
>
Close
</Button>
{onRetry && (
<Button
onClick={onRetry}
data-testid="code-review-retry"
aria-label="Retry code review"
>
<RotateCcw className="w-4 h-4 mr-2" aria-hidden="true" />
Retry
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Render empty state with helpful guidance
if (!review) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md" data-testid="code-review-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileCode className="w-5 h-5 text-brand-500" aria-hidden="true" />
Code Review
</DialogTitle>
<DialogDescription>No review results available yet.</DialogDescription>
</DialogHeader>
<div className="py-6 text-center">
<Info className="w-10 h-10 text-muted-foreground/50 mx-auto mb-3" aria-hidden="true" />
<p className="text-sm text-muted-foreground">
Start a code review to analyze your changes for best practices, security
vulnerabilities, and performance issues.
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
data-testid="code-review-empty-close"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-4xl max-h-[85vh] flex flex-col overflow-hidden"
data-testid="code-review-dialog"
aria-describedby="review-summary"
>
{/* Header */}
<DialogHeader className="flex-shrink-0">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<DialogTitle className="flex items-center gap-2 text-lg">
<FileCode className="w-5 h-5 text-brand-500" aria-hidden="true" />
Code Review Results
</DialogTitle>
<DialogDescription className="mt-1">
Reviewed {review.filesReviewed.length} file
{review.filesReviewed.length !== 1 ? 's' : ''}
{review.gitRef && (
<span
className="ml-1 font-mono text-xs"
aria-label={`Git reference: ${review.gitRef.slice(0, 7)}`}
>
({review.gitRef.slice(0, 7)})
</span>
)}
</DialogDescription>
</div>
<VerdictBadge verdict={review.verdict} />
</div>
</DialogHeader>
{/* Summary section */}
<div className="flex-shrink-0 space-y-3 py-3 border-y border-border/50" id="review-summary">
{/* Summary text */}
<p className="text-sm text-foreground leading-relaxed">{review.summary}</p>
{/* Stats and metadata */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<StatsOverview review={review} />
{review.durationMs && (
<div
className="flex items-center gap-1.5 text-xs text-muted-foreground"
aria-label={`Review completed in ${formatDuration(review.durationMs)}`}
>
<Clock className="w-3.5 h-3.5" aria-hidden="true" />
<span>{formatDuration(review.durationMs)}</span>
</div>
)}
</div>
</div>
{/* Comments section */}
{review.comments.length > 0 ? (
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'severity' | 'file')}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="flex-shrink-0">
<TabsTrigger value="severity">By Severity</TabsTrigger>
<TabsTrigger value="file">By File</TabsTrigger>
</TabsList>
<TabsContent value="severity" className="flex-1 min-h-0 mt-3 overflow-hidden">
<ScrollArea className="h-[350px]">
<Accordion
type="multiple"
defaultValue={['critical', 'high']}
className="space-y-2 pr-4"
>
{SEVERITY_ORDER.map((severity) => {
const comments = commentsBySeverity[severity];
if (!comments || comments.length === 0) return null;
const config = SEVERITY_CONFIG[severity];
const Icon = config.icon;
return (
<AccordionItem
key={severity}
value={severity}
className="border rounded-lg bg-card/30 overflow-hidden"
>
<AccordionTrigger className="px-4 py-3 hover:no-underline hover:bg-accent/30">
<div className="flex items-center gap-2">
<Icon
className={cn(
'w-4 h-4',
severity === 'critical' || severity === 'high'
? 'text-[var(--status-error)]'
: severity === 'medium'
? 'text-[var(--status-warning)]'
: 'text-[var(--status-info)]'
)}
/>
<span className="font-medium">{config.label}</span>
<Badge variant="muted" size="sm">
{comments.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-3">
<div className="space-y-2">
{comments.map((comment) => (
<CommentCard
key={comment.id}
comment={comment}
defaultExpanded={severity === 'critical'}
/>
))}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</ScrollArea>
</TabsContent>
<TabsContent value="file" className="flex-1 min-h-0 mt-3 overflow-hidden">
<ScrollArea className="h-[350px]">
<Accordion type="multiple" className="space-y-2 pr-4">
{Object.entries(commentsByFile).map(([filePath, comments]) => {
// Determine the highest severity in this file
const highestSeverity = comments.reduce((highest, comment) => {
const currentIndex = SEVERITY_ORDER.indexOf(comment.severity);
const highestIndex = SEVERITY_ORDER.indexOf(highest);
return currentIndex < highestIndex ? comment.severity : highest;
}, 'info' as CodeReviewSeverity);
const severityConfig = SEVERITY_CONFIG[highestSeverity];
const Icon = severityConfig.icon;
return (
<AccordionItem
key={filePath}
value={filePath}
className="border rounded-lg bg-card/30 overflow-hidden"
>
<AccordionTrigger className="px-4 py-3 hover:no-underline hover:bg-accent/30">
<div className="flex items-center gap-2 min-w-0">
<FileCode className="w-4 h-4 text-brand-500 flex-shrink-0" />
<span className="font-mono text-sm truncate" title={filePath}>
{filePath}
</span>
<Badge
variant={severityConfig.variant}
size="sm"
className="flex-shrink-0"
>
<Icon className="w-3 h-3 mr-1" />
{comments.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-3">
<div className="space-y-2">
{comments.map((comment) => (
<CommentCard key={comment.id} comment={comment} />
))}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
) : (
<div
className="flex-1 flex items-center justify-center py-8"
role="status"
aria-live="polite"
>
<div className="text-center">
<div className="w-16 h-16 rounded-full bg-[var(--status-success)]/10 flex items-center justify-center mx-auto mb-4">
<CheckCircle2
className="w-10 h-10 text-[var(--status-success)]"
aria-hidden="true"
/>
</div>
<h3 className="text-base font-semibold text-foreground mb-1">No issues found!</h3>
<p className="text-sm text-muted-foreground max-w-xs mx-auto">
Your code looks great. The review found no issues, suggestions, or improvements
needed.
</p>
</div>
</div>
)}
{/* Footer */}
<DialogFooter className="flex-shrink-0 border-t border-border/50 pt-4 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
data-testid="code-review-close"
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,6 @@
export { BoardBackgroundModal } from './board-background-modal';
export { CodeReviewDialog } from './code-review-dialog';
export type { CodeReviewDialogProps } from './code-review-dialog';
export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-dialog';
export { DeleteSessionDialog } from './delete-session-dialog';
export { FileBrowserDialog } from './file-browser-dialog';

View File

@@ -19,6 +19,7 @@ const PROVIDER_ICON_KEYS = {
minimax: 'minimax',
glm: 'glm',
bigpickle: 'bigpickle',
coderabbit: 'coderabbit',
} as const;
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
@@ -113,6 +114,12 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z',
fill: '#4ADE80',
},
coderabbit: {
viewBox: '0 0 24 24',
// CodeRabbit logo - rabbit/bunny icon
path: 'M18 4a2 2 0 0 0-2-2c-.9 0-1.7.6-1.9 1.5l-.3 1.1c-.2.6-.7 1-1.3 1.2L12 6l-.5-.2c-.6-.2-1.1-.6-1.3-1.2l-.3-1.1C9.7 2.6 8.9 2 8 2a2 2 0 0 0-2 2c0 .7.4 1.4 1 1.7V7c0 1.1.9 2 2 2h1v2H8.5C6 11 4 13 4 15.5V18c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-2.5c0-2.5-2-4.5-4.5-4.5H14V9h1c1.1 0 2-.9 2-2V5.7c.6-.3 1-1 1-1.7zm-8 9h4c1.1 0 2 .9 2 2v1H8v-1c0-1.1.9-2 2-2z',
fill: '#FF6B35',
},
};
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
@@ -178,6 +185,10 @@ export function OpenCodeIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.opencode} {...props} />;
}
export function CodeRabbitIcon(props: Omit<ProviderIconProps, 'provider'>) {
return <ProviderIcon provider={PROVIDER_ICON_KEYS.coderabbit} {...props} />;
}
export function DeepSeekIcon({
className,
title,
@@ -569,6 +580,7 @@ export function getProviderIconForModel(
minimax: MiniMaxIcon,
glm: GlmIcon,
bigpickle: BigPickleIcon,
coderabbit: CodeRabbitIcon,
};
return iconMap[iconKey] || AnthropicIcon;

View File

@@ -52,6 +52,8 @@ import {
FollowUpDialog,
PlanApprovalDialog,
} from './board-view/dialogs';
import { CodeReviewDialog } from '@/components/dialogs/code-review-dialog';
import { useCodeReview } from '@/hooks/use-code-review';
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
@@ -167,6 +169,11 @@ export function BoardView() {
// Pipeline settings dialog state
const [showPipelineSettings, setShowPipelineSettings] = useState(false);
// Code review state
const [showCodeReviewDialog, setShowCodeReviewDialog] = useState(false);
const [codeReviewFeature, setCodeReviewFeature] = useState<Feature | null>(null);
const codeReview = useCodeReview();
// Follow-up state hook
const {
showFollowUpDialog,
@@ -1373,6 +1380,44 @@ export function BoardView() {
[currentProject, setPendingPlanApproval]
);
// Handle opening code review for a feature
const handleCodeReview = useCallback(
async (feature: Feature) => {
if (!feature.branchName) {
toast.error('Cannot review code', {
description: 'Feature has no associated branch',
});
return;
}
// Find the worktree for this feature's branch
const featureWorktree = worktrees.find((w) => w.branch === feature.branchName);
const worktreePath = featureWorktree?.path;
if (!worktreePath) {
toast.error('Cannot review code', {
description: 'No worktree found for this feature. Create a worktree first.',
});
return;
}
setCodeReviewFeature(feature);
setShowCodeReviewDialog(true);
// Trigger the code review for the feature's worktree
// Don't pass baseRef - let the backend auto-detect the base branch for worktrees
try {
await codeReview.triggerReview({
projectPath: worktreePath,
// baseRef is omitted - backend will detect main/master for worktrees
});
} catch (error) {
logger.error('Failed to trigger code review:', error);
}
},
[codeReview, worktrees]
);
if (!currentProject) {
return (
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
@@ -1485,6 +1530,7 @@ export function BoardView() {
setSpawnParentFeature(feature);
setShowAddDialog(true);
},
onCodeReview: handleCodeReview,
}}
runningAutoTasks={runningAutoTasks}
pipelineConfig={pipelineConfig}
@@ -1528,6 +1574,7 @@ export function BoardView() {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
onCodeReview={handleCodeReview}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
@@ -1749,6 +1796,26 @@ export function BoardView() {
/>
)}
{/* Code Review Dialog */}
<CodeReviewDialog
open={showCodeReviewDialog}
onOpenChange={(open) => {
setShowCodeReviewDialog(open);
if (!open) {
setCodeReviewFeature(null);
codeReview.clearReview();
}
}}
review={codeReview.review}
loading={codeReview.reviewing}
error={codeReview.error}
onRetry={() => {
if (codeReviewFeature) {
handleCodeReview(codeReviewFeature);
}
}}
/>
{/* Create Worktree Dialog */}
<CreateWorktreeDialog
open={showCreateWorktreeDialog}

View File

@@ -1,5 +1,4 @@
// @ts-nocheck
import { Feature } from '@/store/app-store';
import type { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button';
import {
Edit,
@@ -11,6 +10,7 @@ import {
Eye,
Wand2,
Archive,
FileSearch,
} from 'lucide-react';
interface CardActionsProps {
@@ -30,12 +30,13 @@ interface CardActionsProps {
onComplete?: () => void;
onViewPlan?: () => void;
onApprovePlan?: () => void;
onCodeReview?: () => void;
}
export function CardActions({
feature,
isCurrentAutoTask,
hasContext,
hasContext: _hasContext,
shortcutKey,
isSelectionMode = false,
onEdit,
@@ -49,6 +50,7 @@ export function CardActions({
onComplete,
onViewPlan,
onApprovePlan,
onCodeReview,
}: CardActionsProps) {
// Hide all actions when in selection mode
if (isSelectionMode) {
@@ -258,6 +260,24 @@ export function CardActions({
<span className="truncate">Refine</span>
</Button>
)}
{/* Code Review button - analyzes code for best practices */}
{onCodeReview && (
<Button
variant="outline"
size="sm"
className="h-7 text-[11px] px-2.5 min-w-[44px] gap-1.5 hover:bg-blue-500/10 hover:text-blue-600 hover:border-blue-500/30 dark:hover:text-blue-400 transition-colors"
onClick={(e) => {
e.stopPropagation();
onCodeReview();
}}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`code-review-${feature.id}`}
aria-label="Start code review for this feature"
>
<FileSearch className="w-3.5 h-3.5" aria-hidden="true" />
<span className="sr-only sm:not-sr-only">Review</span>
</Button>
)}
{/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */}
{feature.prUrl && onManualVerify ? (
<Button

View File

@@ -51,6 +51,7 @@ interface KanbanCardProps {
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
onCodeReview?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -84,6 +85,7 @@ export const KanbanCard = memo(function KanbanCard({
onViewPlan,
onApprovePlan,
onSpawnTask,
onCodeReview,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -238,6 +240,7 @@ export const KanbanCard = memo(function KanbanCard({
onComplete={onComplete}
onViewPlan={onViewPlan}
onApprovePlan={onApprovePlan}
onCodeReview={onCodeReview}
/>
</CardContent>
</Card>

View File

@@ -42,6 +42,7 @@ export interface ListViewActionHandlers {
onViewPlan?: (feature: Feature) => void;
onApprovePlan?: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
onCodeReview?: (feature: Feature) => void;
}
export interface ListViewProps {

View File

@@ -14,6 +14,7 @@ import {
GitBranch,
GitFork,
ExternalLink,
FileSearch,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -43,6 +44,7 @@ export interface RowActionHandlers {
onViewPlan?: () => void;
onApprovePlan?: () => void;
onSpawnTask?: () => void;
onCodeReview?: () => void;
}
export interface RowActionsProps {
@@ -479,6 +481,14 @@ export const RowActions = memo(function RowActions({
{handlers.onFollowUp && (
<MenuItem icon={Wand2} label="Refine" onClick={withClose(handlers.onFollowUp)} />
)}
{handlers.onCodeReview && (
<MenuItem
icon={FileSearch}
label="Code Review"
onClick={withClose(handlers.onCodeReview)}
variant="primary"
/>
)}
{feature.prUrl && (
<MenuItem
icon={ExternalLink}
@@ -615,6 +625,7 @@ export function createRowActionHandlers(
viewPlan?: (id: string) => void;
approvePlan?: (id: string) => void;
spawnTask?: (id: string) => void;
codeReview?: (id: string) => void;
}
): RowActionHandlers {
return {
@@ -631,5 +642,6 @@ export function createRowActionHandlers(
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
onCodeReview: actions.codeReview ? () => actions.codeReview!(featureId) : undefined,
};
}

View File

@@ -40,6 +40,7 @@ interface KanbanBoardProps {
onViewPlan: (feature: Feature) => void;
onApprovePlan: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
onCodeReview?: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
onArchiveAllVerified: () => void;
@@ -87,6 +88,7 @@ export function KanbanBoard({
onViewPlan,
onApprovePlan,
onSpawnTask,
onCodeReview,
featuresWithContext,
runningAutoTasks,
onArchiveAllVerified,
@@ -326,6 +328,7 @@ export function KanbanBoard({
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
onCodeReview={onCodeReview ? () => onCodeReview(feature) : undefined}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}

View File

@@ -23,6 +23,7 @@ import {
CursorSettingsTab,
CodexSettingsTab,
OpencodeSettingsTab,
CodeRabbitSettingsTab,
} from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
@@ -123,6 +124,8 @@ export function SettingsView() {
return <CodexSettingsTab />;
case 'opencode-provider':
return <OpencodeSettingsTab />;
case 'coderabbit-provider':
return <CodeRabbitSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;

View File

@@ -0,0 +1,313 @@
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CliStatus } from '../shared/types';
import { CodeRabbitIcon } from '@/components/ui/provider-icon';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
export interface CodeRabbitAuthStatus {
authenticated: boolean;
method: 'oauth' | 'none';
username?: string;
email?: string;
organization?: string;
}
interface CliStatusProps {
status: CliStatus | null;
authStatus?: CodeRabbitAuthStatus | null;
isChecking: boolean;
onRefresh: () => void;
}
function SkeletonPulse({ className }: { className?: string }) {
return <div className={cn('animate-pulse bg-muted/50 rounded', className)} />;
}
export function CodeRabbitCliStatusSkeleton() {
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<SkeletonPulse className="w-9 h-9 rounded-xl" />
<SkeletonPulse className="h-6 w-36" />
</div>
<SkeletonPulse className="w-9 h-9 rounded-lg" />
</div>
<div className="ml-12">
<SkeletonPulse className="h-4 w-80" />
</div>
</div>
<div className="p-6 space-y-4">
{/* Installation status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-40" />
<SkeletonPulse className="h-3 w-32" />
<SkeletonPulse className="h-3 w-48" />
</div>
</div>
{/* Auth status skeleton */}
<div className="flex items-center gap-3 p-4 rounded-xl border border-border/30 bg-muted/10">
<SkeletonPulse className="w-10 h-10 rounded-xl" />
<div className="flex-1 space-y-2">
<SkeletonPulse className="h-4 w-28" />
<SkeletonPulse className="h-3 w-36" />
</div>
</div>
</div>
</div>
);
}
export function CodeRabbitCliStatus({ status, authStatus, isChecking, onRefresh }: CliStatusProps) {
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [isDeauthenticating, setIsDeauthenticating] = useState(false);
const handleSignIn = useCallback(async () => {
setIsAuthenticating(true);
try {
const api = getElectronAPI();
const result = (await api.setup.authCodeRabbit()) as {
success: boolean;
requiresManualAuth?: boolean;
command?: string;
message?: string;
error?: string;
};
if (result.success && result.requiresManualAuth) {
// Show toast with instructions to run command manually
toast.info('Manual Authentication Required', {
description:
result.message || `Please run "${result.command || 'cr auth login'}" in your terminal`,
duration: 10000,
});
} else if (result.success) {
toast.success('Signed In', {
description: 'Successfully authenticated CodeRabbit CLI',
});
onRefresh();
} else if (result.error) {
toast.error('Authentication Failed', {
description: result.error,
});
}
} catch (error) {
toast.error('Authentication Failed', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsAuthenticating(false);
}
}, [onRefresh]);
const handleSignOut = useCallback(async () => {
setIsDeauthenticating(true);
try {
const api = getElectronAPI();
const result = await api.setup.deauthCodeRabbit();
if (result.success) {
toast.success('Signed Out', {
description: 'Successfully signed out from CodeRabbit CLI',
});
onRefresh();
} else if (result.error) {
toast.error('Sign Out Failed', {
description: result.error,
});
}
} catch (error) {
toast.error('Sign Out Failed', {
description: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsDeauthenticating(false);
}
}, [onRefresh]);
if (!status) return <CodeRabbitCliStatusSkeleton />;
return (
<div
className={cn(
'rounded-2xl overflow-hidden',
'border border-border/50',
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
'shadow-sm shadow-black/5'
)}
>
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-orange-500/20 to-orange-600/10 flex items-center justify-center border border-orange-500/20">
<CodeRabbitIcon className="w-5 h-5 text-orange-500" />
</div>
<h2 className="text-lg font-semibold text-foreground tracking-tight">CodeRabbit CLI</h2>
</div>
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={isChecking}
data-testid="refresh-coderabbit-cli"
title="Refresh CodeRabbit CLI detection"
className={cn(
'h-9 w-9 rounded-lg',
'hover:bg-accent/50 hover:scale-105',
'transition-all duration-200'
)}
>
{isChecking ? <Spinner size="sm" /> : <RefreshCw className="w-4 h-4" />}
</Button>
</div>
<p className="text-sm text-muted-foreground/80 ml-12">
CodeRabbit CLI powers AI-driven code reviews with detailed analysis and suggestions.
</p>
</div>
<div className="p-6 space-y-4">
{status.success && status.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">CodeRabbit CLI Installed</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{status.version && (
<p>
Version: <span className="font-mono">{status.version}</span>
</p>
)}
{status.path && (
<p className="truncate" title={status.path}>
Path: <span className="font-mono text-[10px]">{status.path}</span>
</p>
)}
</div>
</div>
</div>
{/* Authentication Status */}
{authStatus?.authenticated ? (
<div className="flex items-start gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-emerald-400">Authenticated</p>
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
{authStatus.username && (
<p>
Username: <span className="font-mono">{authStatus.username}</span>
</p>
)}
{/* {authStatus.email && (
<p>
Email: <span className="font-mono">{authStatus.email}</span>
</p>
)} */}
{authStatus.organization && (
<p>
Organization: <span className="font-mono">{authStatus.organization}</span>
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={handleSignOut}
disabled={isDeauthenticating}
className="mt-3 h-8 text-xs"
>
{isDeauthenticating ? 'Signing Out...' : 'Sign Out'}
</Button>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<XCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">Not Authenticated</p>
<p className="text-xs text-amber-400/70 mt-1">
Click Sign In to authenticate via OAuth in your browser.
</p>
<Button
variant="outline"
size="sm"
onClick={handleSignIn}
disabled={isAuthenticating}
className="mt-3 h-8 text-xs"
>
{isAuthenticating ? 'Requesting...' : 'Sign In'}
</Button>
</div>
</div>
)}
{status.recommendation && (
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
<AlertCircle className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium text-amber-400">CodeRabbit CLI Not Detected</p>
<p className="text-xs text-amber-400/70 mt-1">
{status.recommendation ||
'Install CodeRabbit CLI to enable AI-powered code reviews.'}
</p>
</div>
</div>
{status.installCommands && (
<div className="space-y-3">
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
<div className="space-y-2">
{status.installCommands.macos && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
macOS/Linux
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.macos}
</code>
</div>
)}
{status.installCommands.npm && (
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
npm
</p>
<code className="text-xs text-foreground/80 font-mono break-all">
{status.installCommands.npm}
</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -17,7 +17,13 @@ import {
Code2,
Webhook,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
CodeRabbitIcon,
} from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
export interface NavigationItem {
@@ -51,6 +57,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
{ id: 'coderabbit-provider', label: 'CodeRabbit', icon: CodeRabbitIcon },
],
},
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },

View File

@@ -8,6 +8,7 @@ export type SettingsViewId =
| 'cursor-provider'
| 'codex-provider'
| 'opencode-provider'
| 'coderabbit-provider'
| 'mcp-servers'
| 'prompts'
| 'model-defaults'

View File

@@ -0,0 +1,125 @@
import { useState, useCallback, useEffect } from 'react';
import { toast } from 'sonner';
import {
CodeRabbitCliStatus,
CodeRabbitCliStatusSkeleton,
} from '../cli-status/coderabbit-cli-status';
import type { CodeRabbitAuthStatus } from '../cli-status/coderabbit-cli-status';
import { getElectronAPI } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type { CliStatus as SharedCliStatus } from '../shared/types';
const logger = createLogger('CodeRabbitSettings');
export function CodeRabbitSettingsTab() {
// Start with isCheckingCli=true to show skeleton on initial load
const [isCheckingCli, setIsCheckingCli] = useState(true);
const [cliStatus, setCliStatus] = useState<SharedCliStatus | null>(null);
const [authStatus, setAuthStatus] = useState<CodeRabbitAuthStatus | null>(null);
// Load CLI status on mount
useEffect(() => {
const checkStatus = async () => {
setIsCheckingCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getCodeRabbitStatus) {
const result = await api.setup.getCodeRabbitStatus();
setCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
method: result.auth.method || 'none',
username: result.auth.username,
email: result.auth.email,
organization: result.auth.organization,
});
}
} else {
setCliStatus({
success: false,
status: 'not_installed',
recommendation: 'CodeRabbit CLI detection is only available in desktop mode.',
});
}
} catch (error) {
logger.error('Failed to check CodeRabbit CLI status:', error);
setCliStatus({
success: false,
status: 'not_installed',
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
setIsCheckingCli(false);
}
};
checkStatus();
}, []);
const handleRefresh = useCallback(async () => {
setIsCheckingCli(true);
try {
const api = getElectronAPI();
if (api?.setup?.getCodeRabbitStatus) {
const result = await api.setup.getCodeRabbitStatus();
setCliStatus({
success: result.success,
status: result.installed ? 'installed' : 'not_installed',
version: result.version,
path: result.path,
recommendation: result.recommendation,
installCommands: result.installCommands,
});
if (result.auth) {
setAuthStatus({
authenticated: result.auth.authenticated,
method: result.auth.method || 'none',
username: result.auth.username,
email: result.auth.email,
organization: result.auth.organization,
});
} else {
setAuthStatus(null);
}
if (result.installed) {
toast.success('CodeRabbit CLI refreshed');
}
}
} catch (error) {
logger.error('Failed to refresh CodeRabbit CLI status:', error);
toast.error('Failed to refresh CodeRabbit CLI status');
} finally {
setIsCheckingCli(false);
}
}, []);
// Show skeleton only while checking CLI status initially
if (!cliStatus && isCheckingCli) {
return (
<div className="space-y-6">
<CodeRabbitCliStatusSkeleton />
</div>
);
}
return (
<div className="space-y-6">
<CodeRabbitCliStatus
status={cliStatus}
authStatus={authStatus}
isChecking={isCheckingCli}
onRefresh={handleRefresh}
/>
</div>
);
}
export default CodeRabbitSettingsTab;

View File

@@ -3,3 +3,4 @@ export { ClaudeSettingsTab } from './claude-settings-tab';
export { CursorSettingsTab } from './cursor-settings-tab';
export { CodexSettingsTab } from './codex-settings-tab';
export { OpencodeSettingsTab } from './opencode-settings-tab';
export { CodeRabbitSettingsTab } from './coderabbit-settings-tab';

View File

@@ -1,5 +1,12 @@
export { useAutoMode } from './use-auto-mode';
export { useBoardBackgroundSettings } from './use-board-background-settings';
export {
useCodeReview,
type TriggerReviewOptions,
type ReviewProgress,
type ReviewProviderStatus,
type UseCodeReviewResult,
} from './use-code-review';
export { useElectronAgent } from './use-electron-agent';
export { useGuidedPrompts } from './use-guided-prompts';
export { useKeyboardShortcuts } from './use-keyboard-shortcuts';

View File

@@ -0,0 +1,400 @@
/**
* useCodeReview Hook
*
* Custom hook for interacting with the code review API.
* Provides functionality to trigger, monitor, and manage code reviews.
*
* Features:
* - Trigger code reviews with customizable options
* - Real-time progress updates via WebSocket events
* - Stop in-progress reviews
* - Check available providers
* - Track review status and results
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { pathsEqual } from '@/lib/utils';
import type {
CodeReviewResult,
CodeReviewComment,
CodeReviewCategory,
CodeReviewEvent,
ModelId,
ThinkingLevel,
} from '@automaker/types';
const logger = createLogger('useCodeReview');
/**
* Options for triggering a code review
*/
export interface TriggerReviewOptions {
/** Project path to review (overrides default). Use this for worktree paths. */
projectPath?: string;
/** Specific files to review (if empty, reviews git diff) */
files?: string[];
/** Git ref to compare against. If not provided and reviewing a worktree, auto-detects base branch. */
baseRef?: string;
/** Categories to focus on */
categories?: CodeReviewCategory[];
/** Whether to attempt auto-fixes for issues found */
autoFix?: boolean;
/** Model to use for the review */
model?: ModelId;
/** Thinking level for extended reasoning */
thinkingLevel?: ThinkingLevel;
}
/**
* Review progress information
*/
export interface ReviewProgress {
currentFile: string;
filesCompleted: number;
filesTotal: number;
content?: string;
}
/**
* Provider status information
*/
export interface ReviewProviderStatus {
provider: 'claude' | 'codex' | 'cursor' | 'coderabbit';
available: boolean;
authenticated: boolean;
version?: string;
issues: string[];
}
/**
* Return type for the useCodeReview hook
*/
export interface UseCodeReviewResult {
// State
/** Whether the initial data is loading */
loading: boolean;
/** Whether a review is currently in progress */
reviewing: boolean;
/** Current error message, if any */
error: string | null;
// Data
/** The most recent review result */
review: CodeReviewResult | null;
/** Current review progress (during review) */
progress: ReviewProgress | null;
/** Comments accumulated during the review */
comments: CodeReviewComment[];
/** Available review providers */
providers: ReviewProviderStatus[];
/** Recommended provider for code reviews */
recommendedProvider: string | null;
// Actions
/** Start a new code review */
triggerReview: (options?: TriggerReviewOptions) => Promise<void>;
/** Stop the current review */
stopReview: () => Promise<void>;
/** Refresh provider status */
refreshProviders: (forceRefresh?: boolean) => Promise<void>;
/** Clear the current error */
clearError: () => void;
/** Clear the review results */
clearReview: () => void;
}
/**
* Hook for managing code reviews
*
* @param projectPath - Optional project path override. If not provided, uses current project from store.
* @returns Code review state and actions
*
* @example
* ```tsx
* const { triggerReview, reviewing, review, progress, error } = useCodeReview();
*
* // Trigger a review with default options
* await triggerReview();
*
* // Trigger a review with specific options
* await triggerReview({
* categories: ['security', 'performance'],
* model: 'claude-sonnet-4-20250514',
* });
* ```
*/
export function useCodeReview(projectPath?: string): UseCodeReviewResult {
const { currentProject } = useAppStore();
const effectiveProjectPath = projectPath ?? currentProject?.path ?? null;
// State
const [loading, setLoading] = useState(false);
const [reviewing, setReviewing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [review, setReview] = useState<CodeReviewResult | null>(null);
const [progress, setProgress] = useState<ReviewProgress | null>(null);
const [comments, setComments] = useState<CodeReviewComment[]>([]);
const [providers, setProviders] = useState<ReviewProviderStatus[]>([]);
const [recommendedProvider, setRecommendedProvider] = useState<string | null>(null);
// Refs for cleanup and tracking
const isMountedRef = useRef(true);
// Track the active review path for event matching (may differ from effectiveProjectPath for worktrees)
const activeReviewPathRef = useRef<string | null>(null);
/**
* Refresh provider status
*/
const refreshProviders = useCallback(async (forceRefresh = false) => {
if (!isMountedRef.current) return;
try {
setLoading(true);
const api = getHttpApiClient();
const response = await api.codeReview.getProviders(forceRefresh);
if (isMountedRef.current) {
if (response.success) {
setProviders(response.providers || []);
setRecommendedProvider(response.recommended || null);
} else {
logger.warn('Failed to fetch providers:', response.error);
}
}
} catch (err) {
if (isMountedRef.current) {
logger.error('Error fetching providers:', err);
}
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
}, []);
/**
* Check review status
*/
const checkStatus = useCallback(async () => {
try {
const api = getHttpApiClient();
const response = await api.codeReview.status();
if (isMountedRef.current && response.success) {
setReviewing(response.isRunning || false);
}
} catch (err) {
logger.error('Error checking status:', err);
}
}, []);
/**
* Trigger a new code review
*/
const triggerReview = useCallback(
async (options: TriggerReviewOptions = {}) => {
// Use provided projectPath if available, otherwise fall back to effective path
const reviewPath = options.projectPath ?? effectiveProjectPath;
if (!reviewPath) {
setError('No project selected');
return;
}
if (reviewing) {
setError('A code review is already in progress');
return;
}
try {
if (isMountedRef.current) {
// Track the path being reviewed for event matching
activeReviewPathRef.current = reviewPath;
setError(null);
setReview(null);
setProgress(null);
setComments([]);
setReviewing(true);
}
const api = getHttpApiClient();
const response = await api.codeReview.trigger(reviewPath, {
files: options.files,
baseRef: options.baseRef,
categories: options.categories,
autoFix: options.autoFix,
model: options.model,
thinkingLevel: options.thinkingLevel,
});
if (!response.success) {
throw new Error(response.error || 'Failed to start code review');
}
logger.info('Code review triggered successfully', { projectPath: reviewPath });
} catch (err) {
if (isMountedRef.current) {
const errorMessage = err instanceof Error ? err.message : 'Failed to trigger code review';
logger.error('Error triggering review:', err);
setError(errorMessage);
setReviewing(false);
activeReviewPathRef.current = null;
}
}
},
[effectiveProjectPath, reviewing]
);
/**
* Stop the current review
*/
const stopReview = useCallback(async () => {
try {
const api = getHttpApiClient();
const response = await api.codeReview.stop();
if (isMountedRef.current) {
if (response.success) {
setReviewing(false);
setProgress(null);
activeReviewPathRef.current = null;
logger.info('Code review stopped');
} else {
setError(response.error || 'Failed to stop review');
}
}
} catch (err) {
if (isMountedRef.current) {
const errorMessage = err instanceof Error ? err.message : 'Failed to stop review';
logger.error('Error stopping review:', err);
setError(errorMessage);
}
}
}, []);
/**
* Clear the current error
*/
const clearError = useCallback(() => {
setError(null);
}, []);
/**
* Clear the review results
*/
const clearReview = useCallback(() => {
setReview(null);
setComments([]);
setProgress(null);
setError(null);
activeReviewPathRef.current = null;
}, []);
/**
* Handle code review events from WebSocket
*/
const handleCodeReviewEvent = useCallback(
(event: CodeReviewEvent) => {
if (!isMountedRef.current) return;
// Match events against the active review path (for worktrees) or effective project path
const matchPath = activeReviewPathRef.current ?? effectiveProjectPath;
if (matchPath && !pathsEqual(event.projectPath, matchPath)) {
return;
}
switch (event.type) {
case 'code_review_start':
logger.info('Code review started', { filesCount: event.filesCount });
setReviewing(true);
setProgress({
currentFile: '',
filesCompleted: 0,
filesTotal: event.filesCount,
});
setComments([]);
break;
case 'code_review_progress':
setProgress({
currentFile: event.currentFile,
filesCompleted: event.filesCompleted,
filesTotal: event.filesTotal,
content: event.content,
});
break;
case 'code_review_comment':
setComments((prev) => [...prev, event.comment]);
break;
case 'code_review_complete':
logger.info('Code review completed', {
verdict: event.result.verdict,
commentsCount: event.result.comments.length,
});
setReview(event.result);
setReviewing(false);
setProgress(null);
activeReviewPathRef.current = null;
break;
case 'code_review_error':
logger.error('Code review error:', event.error);
setError(event.error);
setReviewing(false);
setProgress(null);
activeReviewPathRef.current = null;
break;
}
},
[effectiveProjectPath]
);
// Subscribe to WebSocket events
useEffect(() => {
isMountedRef.current = true;
const api = getHttpApiClient();
// Subscribe to code review events using the codeReview API
const unsubscribe = api.codeReview.onEvent(handleCodeReviewEvent);
// Initial status check
checkStatus();
return () => {
isMountedRef.current = false;
unsubscribe();
};
}, [handleCodeReviewEvent, checkStatus]);
// Load providers on mount
useEffect(() => {
refreshProviders();
}, [refreshProviders]);
return {
// State
loading,
reviewing,
error,
// Data
review,
progress,
comments,
providers,
recommendedProvider,
// Actions
triggerReview,
stopReview,
refreshProviders,
clearError,
clearReview,
};
}

View File

@@ -1438,6 +1438,37 @@ interface SetupAPI {
user: string | null;
error?: string;
}>;
getCodeRabbitStatus?: () => Promise<{
success: boolean;
installed?: boolean;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
npm?: string;
};
auth?: {
authenticated: boolean;
method: 'oauth' | 'none';
username?: string;
email?: string;
organization?: string;
};
error?: string;
}>;
authCodeRabbit?: () => Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
message?: string;
error?: string;
}>;
deauthCodeRabbit?: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void;
}

View File

@@ -35,11 +35,18 @@ import type {
NotificationsAPI,
EventHistoryAPI,
} from './electron';
import type { EventHistoryFilter } from '@automaker/types';
import type {
EventHistoryFilter,
ModelId,
ThinkingLevel,
ReasoningEffort,
CodeReviewCategory,
CodeReviewEvent,
CodeReviewResult,
} from '@automaker/types';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
import type { ModelId, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
const logger = createLogger('HttpClient');
@@ -524,7 +531,8 @@ type EventType =
| 'dev-server:started'
| 'dev-server:output'
| 'dev-server:stopped'
| 'notification:created';
| 'notification:created'
| 'code_review:event';
/**
* Dev server log event payloads for WebSocket streaming
@@ -1473,6 +1481,15 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }),
verifyCodeRabbitAuth: (
authMethod: 'cli' | 'api_key',
apiKey?: string
): Promise<{
success: boolean;
authenticated: boolean;
error?: string;
}> => this.post('/api/setup/verify-coderabbit-auth', { authMethod, apiKey }),
// OpenCode CLI methods
getOpencodeStatus: (): Promise<{
success: boolean;
@@ -1560,6 +1577,41 @@ export class HttpApiClient implements ElectronAPI {
error?: string;
}> => this.post('/api/setup/opencode/cache/clear'),
// CodeRabbit CLI methods
getCodeRabbitStatus: (): Promise<{
success: boolean;
installed?: boolean;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
npm?: string;
};
auth?: {
authenticated: boolean;
method: 'oauth' | 'none';
username?: string;
email?: string;
organization?: string;
};
error?: string;
}> => this.get('/api/setup/coderabbit-status'),
authCodeRabbit: (): Promise<{
success: boolean;
requiresManualAuth?: boolean;
command?: string;
message?: string;
error?: string;
}> => this.post('/api/setup/auth-coderabbit'),
deauthCodeRabbit: (): Promise<{
success: boolean;
message?: string;
error?: string;
}> => this.post('/api/setup/deauth-coderabbit'),
onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent('agent:stream', callback);
},
@@ -2309,6 +2361,81 @@ export class HttpApiClient implements ElectronAPI {
},
};
// Code Review API
codeReview = {
/**
* Trigger a new code review on the specified project
* @param projectPath - Path to the project to review
* @param options - Optional configuration for the review
* @returns Promise with success status and message
*/
trigger: (
projectPath: string,
options?: {
/** Specific files to review (if empty, reviews git diff) */
files?: string[];
/** Git ref to compare against (default: HEAD~1) */
baseRef?: string;
/** Categories to focus on */
categories?: CodeReviewCategory[];
/** Whether to attempt auto-fixes for issues found */
autoFix?: boolean;
/** Model to use for the review */
model?: ModelId;
/** Thinking level for extended reasoning */
thinkingLevel?: ThinkingLevel;
}
): Promise<{ success: boolean; message?: string; error?: string }> =>
this.post('/api/code-review/trigger', { projectPath, ...options }),
/**
* Get the current code review status
* @returns Promise with running status and project path
*/
status: (): Promise<{
success: boolean;
isRunning: boolean;
projectPath?: string;
error?: string;
}> => this.get('/api/code-review/status'),
/**
* Stop the currently running code review
* @returns Promise with success status and message
*/
stop: (): Promise<{ success: boolean; message?: string; error?: string }> =>
this.post('/api/code-review/stop', {}),
/**
* Get available code review providers and their status
* @param forceRefresh - Force refresh of cached provider status
* @returns Promise with list of providers and recommended provider
*/
getProviders: (
forceRefresh = false
): Promise<{
success: boolean;
providers?: Array<{
provider: 'claude' | 'codex' | 'cursor';
available: boolean;
authenticated: boolean;
version?: string;
issues: string[];
}>;
recommended?: string | null;
error?: string;
}> => this.get(`/api/code-review/providers${forceRefresh ? '?refresh=true' : ''}`),
/**
* Subscribe to code review events via WebSocket
* @param callback - Function to call when a code review event is received
* @returns Unsubscribe function
*/
onEvent: (callback: (event: CodeReviewEvent) => void): (() => void) => {
return this.subscribeToEvent('code_review:event', callback as EventCallback);
},
};
// Context API
context = {
describeImage: (

View File

@@ -0,0 +1,184 @@
/**
* Code Review Types
*
* Types for code review functionality in AutoMaker.
* Used for automated code review results and comments.
*/
import type { ModelId } from './model.js';
/**
* Severity level of a code review comment
*/
export type CodeReviewSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
/**
* Category of code review finding
*/
export type CodeReviewCategory =
| 'tech_stack'
| 'security'
| 'code_quality'
| 'implementation'
| 'architecture'
| 'performance'
| 'testing'
| 'documentation';
/**
* Overall verdict of a code review
*/
export type CodeReviewVerdict = 'approved' | 'changes_requested' | 'needs_discussion';
/**
* A single comment in a code review
*/
export interface CodeReviewComment {
/** Unique identifier for the comment */
id: string;
/** File path relative to project root */
filePath: string;
/** Starting line number (1-based) */
startLine: number;
/** Ending line number (1-based), same as startLine for single-line comments */
endLine: number;
/** The comment text/feedback */
body: string;
/** Severity level of the issue */
severity: CodeReviewSeverity;
/** Category of the finding */
category: CodeReviewCategory;
/** Suggested fix or improvement (if applicable) */
suggestedFix?: string;
/** Code snippet showing the suggested change */
suggestedCode?: string;
/** Whether this issue was auto-fixed */
autoFixed?: boolean;
/** ISO timestamp when the comment was created */
createdAt: string;
}
/**
* Summary statistics for a code review
*/
export interface CodeReviewSummary {
/** Total number of comments */
totalComments: number;
/** Count by severity */
bySeverity: Record<CodeReviewSeverity, number>;
/** Count by category */
byCategory: Record<CodeReviewCategory, number>;
/** Number of issues that were auto-fixed */
autoFixedCount: number;
}
/**
* Result of a code review analysis
*/
export interface CodeReviewResult {
/** Unique identifier for this review */
id: string;
/** Overall verdict of the review */
verdict: CodeReviewVerdict;
/** Summary of the review findings */
summary: string;
/** Detailed review comments */
comments: CodeReviewComment[];
/** Aggregated statistics */
stats: CodeReviewSummary;
/** Files that were reviewed */
filesReviewed: string[];
/** Model used for the review */
model: ModelId;
/** ISO timestamp when the review was performed */
reviewedAt: string;
/** Git commit SHA or branch that was reviewed (if applicable) */
gitRef?: string;
/** Duration of the review in milliseconds */
durationMs?: number;
}
/**
* Request payload for code review endpoint
*/
export interface CodeReviewRequest {
/** Project path to review */
projectPath: string;
/** Specific files to review (if empty, reviews git diff) */
files?: string[];
/** Git ref to compare against (default: HEAD) */
baseRef?: string;
/** Categories to focus on (if empty, reviews all categories) */
categories?: CodeReviewCategory[];
/** Whether to attempt auto-fixes for issues found */
autoFix?: boolean;
}
/**
* Successful response from code review endpoint
*/
export interface CodeReviewResponse {
success: true;
review: CodeReviewResult;
}
/**
* Error response from code review endpoint
*/
export interface CodeReviewErrorResponse {
success: false;
error: string;
}
/**
* Events emitted during async code review
*/
export type CodeReviewEvent =
| {
type: 'code_review_start';
projectPath: string;
filesCount: number;
}
| {
type: 'code_review_progress';
projectPath: string;
currentFile: string;
filesCompleted: number;
filesTotal: number;
content?: string;
}
| {
type: 'code_review_comment';
projectPath: string;
comment: CodeReviewComment;
}
| {
type: 'code_review_complete';
projectPath: string;
result: CodeReviewResult;
}
| {
type: 'code_review_error';
projectPath: string;
error: string;
};
/**
* Stored code review data with metadata for cache
*/
export interface StoredCodeReview {
/** Unique identifier */
id: string;
/** Project path that was reviewed */
projectPath: string;
/** Git ref that was reviewed */
gitRef?: string;
/** ISO timestamp when review was performed */
reviewedAt: string;
/** Model used for review */
model: ModelId;
/** The review result */
result: CodeReviewResult;
/** ISO timestamp when user viewed this review (undefined = not yet viewed) */
viewedAt?: string;
}

View File

@@ -47,6 +47,7 @@ export type EventType =
| 'dev-server:started'
| 'dev-server:output'
| 'dev-server:stopped'
| 'notification:created';
| 'notification:created'
| 'code_review:event';
export type EventCallback = (type: EventType, payload: unknown) => void;

View File

@@ -296,3 +296,18 @@ export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-hist
// Worktree and PR types
export type { PRState, WorktreePRInfo } from './worktree.js';
export { PR_STATES, validatePRState } from './worktree.js';
// Code review types
export type {
CodeReviewSeverity,
CodeReviewCategory,
CodeReviewVerdict,
CodeReviewComment,
CodeReviewSummary,
CodeReviewResult,
CodeReviewRequest,
CodeReviewResponse,
CodeReviewErrorResponse,
CodeReviewEvent,
StoredCodeReview,
} from './code-review.js';

View File

@@ -669,6 +669,8 @@ export interface Credentials {
google: string;
/** OpenAI API key (for compatibility or alternative providers) */
openai: string;
/** CodeRabbit API key (for AI-powered code reviews) */
coderabbit: string;
};
}
@@ -905,6 +907,7 @@ export const DEFAULT_CREDENTIALS: Credentials = {
anthropic: '',
google: '',
openai: '',
coderabbit: '',
},
};