Compare commits

...

1 Commits

Author SHA1 Message Date
Shirone
5b620011ad 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.
2026-01-24 21:10:33 +01:00
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 { getNotificationService } from './services/notification-service.js';
import { createEventHistoryRoutes } from './routes/event-history/index.js'; import { createEventHistoryRoutes } from './routes/event-history/index.js';
import { getEventHistoryService } from './services/event-history-service.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 // Load environment variables
dotenv.config(); dotenv.config();
@@ -209,6 +211,7 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
const codexUsageService = new CodexUsageService(codexAppServerService); const codexUsageService = new CodexUsageService(codexAppServerService);
const mcpTestService = new MCPTestService(settingsService); const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader); const ideationService = new IdeationService(events, settingsService, featureLoader);
const codeReviewService = new CodeReviewService(events, settingsService);
// Initialize DevServerService with event emitter for real-time log streaming // Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService(); const devServerService = getDevServerService();
@@ -300,6 +303,7 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader)); app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
app.use('/api/notifications', createNotificationsRoutes(notificationService)); app.use('/api/notifications', createNotificationsRoutes(notificationService));
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService)); app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
app.use('/api/code-review', createCodeReviewRoutes(codeReviewService));
// Create HTTP server // Create HTTP server
const server = createServer(app); const server = createServer(app);

View File

@@ -40,6 +40,7 @@ export interface UnifiedCliDetection {
claude?: CliDetectionResult; claude?: CliDetectionResult;
codex?: CliDetectionResult; codex?: CliDetectionResult;
cursor?: CliDetectionResult; cursor?: CliDetectionResult;
coderabbit?: CliDetectionResult;
} }
/** /**
@@ -76,6 +77,16 @@ const CLI_CONFIGS = {
win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex', 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; } as const;
/** /**
@@ -230,6 +241,8 @@ export async function checkCliAuth(
return await checkCodexAuth(command); return await checkCodexAuth(command);
case 'cursor': case 'cursor':
return await checkCursorAuth(command); return await checkCursorAuth(command);
case 'coderabbit':
return await checkCodeRabbitAuth(command);
default: default:
return 'none'; return 'none';
} }
@@ -355,6 +368,64 @@ async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'no
return 'none'; 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 * 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 { Router } from 'express';
import { createStatusHandler } from './routes/status.js';
import { createClaudeStatusHandler } from './routes/claude-status.js'; import { createClaudeStatusHandler } from './routes/claude-status.js';
import { createInstallClaudeHandler } from './routes/install-claude.js'; import { createInstallClaudeHandler } from './routes/install-claude.js';
import { createAuthClaudeHandler } from './routes/auth-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 { createPlatformHandler } from './routes/platform.js';
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js'; import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
import { createVerifyCodexAuthHandler } from './routes/verify-codex-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 { createGhStatusHandler } from './routes/gh-status.js';
import { createCursorStatusHandler } from './routes/cursor-status.js'; import { createCursorStatusHandler } from './routes/cursor-status.js';
import { createCodexStatusHandler } from './routes/codex-status.js'; import { createCodexStatusHandler } from './routes/codex-status.js';
@@ -44,6 +49,9 @@ import {
export function createSetupRoutes(): Router { export function createSetupRoutes(): Router {
const router = Router(); const router = Router();
// Unified CLI status endpoint
router.get('/status', createStatusHandler());
router.get('/claude-status', createClaudeStatusHandler()); router.get('/claude-status', createClaudeStatusHandler());
router.post('/install-claude', createInstallClaudeHandler()); router.post('/install-claude', createInstallClaudeHandler());
router.post('/auth-claude', createAuthClaudeHandler()); router.post('/auth-claude', createAuthClaudeHandler());
@@ -54,6 +62,7 @@ export function createSetupRoutes(): Router {
router.get('/platform', createPlatformHandler()); router.get('/platform', createPlatformHandler());
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler()); router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
router.post('/verify-codex-auth', createVerifyCodexAuthHandler()); router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
router.post('/verify-coderabbit-auth', createVerifyCodeRabbitAuthHandler());
router.get('/gh-status', createGhStatusHandler()); router.get('/gh-status', createGhStatusHandler());
// Cursor CLI routes // Cursor CLI routes
@@ -72,6 +81,11 @@ export function createSetupRoutes(): Router {
router.post('/auth-opencode', createAuthOpencodeHandler()); router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler()); 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 // OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler()); router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler()); 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 }; anthropic: { configured: boolean; masked: string };
google: { configured: boolean; masked: string }; google: { configured: boolean; masked: string };
openai: { configured: boolean; masked: string }; openai: { configured: boolean; masked: string };
coderabbit: { configured: boolean; masked: string };
}> { }> {
const credentials = await this.getCredentials(); const credentials = await this.getCredentials();
@@ -441,6 +442,10 @@ export class SettingsService {
configured: !!credentials.apiKeys.openai, configured: !!credentials.apiKeys.openai,
masked: maskKey(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; anthropic?: string;
google?: string; google?: string;
openai?: string; openai?: string;
coderabbit?: string;
}; };
await this.updateCredentials({ await this.updateCredentials({
apiKeys: { apiKeys: {
anthropic: apiKeys.anthropic || '', anthropic: apiKeys.anthropic || '',
google: apiKeys.google || '', google: apiKeys.google || '',
openai: apiKeys.openai || '', openai: apiKeys.openai || '',
coderabbit: apiKeys.coderabbit || '',
}, },
}); });
migratedCredentials = true; 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 { 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 { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-dialog';
export { DeleteSessionDialog } from './delete-session-dialog'; export { DeleteSessionDialog } from './delete-session-dialog';
export { FileBrowserDialog } from './file-browser-dialog'; export { FileBrowserDialog } from './file-browser-dialog';

View File

@@ -19,6 +19,7 @@ const PROVIDER_ICON_KEYS = {
minimax: 'minimax', minimax: 'minimax',
glm: 'glm', glm: 'glm',
bigpickle: 'bigpickle', bigpickle: 'bigpickle',
coderabbit: 'coderabbit',
} as const; } as const;
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS; 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', 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', 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'> { 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} />; 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({ export function DeepSeekIcon({
className, className,
title, title,
@@ -569,6 +580,7 @@ export function getProviderIconForModel(
minimax: MiniMaxIcon, minimax: MiniMaxIcon,
glm: GlmIcon, glm: GlmIcon,
bigpickle: BigPickleIcon, bigpickle: BigPickleIcon,
coderabbit: CodeRabbitIcon,
}; };
return iconMap[iconKey] || AnthropicIcon; return iconMap[iconKey] || AnthropicIcon;

View File

@@ -52,6 +52,8 @@ import {
FollowUpDialog, FollowUpDialog,
PlanApprovalDialog, PlanApprovalDialog,
} from './board-view/dialogs'; } 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 { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog'; import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog'; import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
@@ -167,6 +169,11 @@ export function BoardView() {
// Pipeline settings dialog state // Pipeline settings dialog state
const [showPipelineSettings, setShowPipelineSettings] = useState(false); 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 // Follow-up state hook
const { const {
showFollowUpDialog, showFollowUpDialog,
@@ -1373,6 +1380,44 @@ export function BoardView() {
[currentProject, setPendingPlanApproval] [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) { if (!currentProject) {
return ( return (
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project"> <div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
@@ -1485,6 +1530,7 @@ export function BoardView() {
setSpawnParentFeature(feature); setSpawnParentFeature(feature);
setShowAddDialog(true); setShowAddDialog(true);
}, },
onCodeReview: handleCodeReview,
}} }}
runningAutoTasks={runningAutoTasks} runningAutoTasks={runningAutoTasks}
pipelineConfig={pipelineConfig} pipelineConfig={pipelineConfig}
@@ -1528,6 +1574,7 @@ export function BoardView() {
setSpawnParentFeature(feature); setSpawnParentFeature(feature);
setShowAddDialog(true); setShowAddDialog(true);
}} }}
onCodeReview={handleCodeReview}
featuresWithContext={featuresWithContext} featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasks} runningAutoTasks={runningAutoTasks}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} 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 */} {/* Create Worktree Dialog */}
<CreateWorktreeDialog <CreateWorktreeDialog
open={showCreateWorktreeDialog} open={showCreateWorktreeDialog}

View File

@@ -1,5 +1,4 @@
// @ts-nocheck import type { Feature } from '@/store/app-store';
import { Feature } from '@/store/app-store';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Edit, Edit,
@@ -11,6 +10,7 @@ import {
Eye, Eye,
Wand2, Wand2,
Archive, Archive,
FileSearch,
} from 'lucide-react'; } from 'lucide-react';
interface CardActionsProps { interface CardActionsProps {
@@ -30,12 +30,13 @@ interface CardActionsProps {
onComplete?: () => void; onComplete?: () => void;
onViewPlan?: () => void; onViewPlan?: () => void;
onApprovePlan?: () => void; onApprovePlan?: () => void;
onCodeReview?: () => void;
} }
export function CardActions({ export function CardActions({
feature, feature,
isCurrentAutoTask, isCurrentAutoTask,
hasContext, hasContext: _hasContext,
shortcutKey, shortcutKey,
isSelectionMode = false, isSelectionMode = false,
onEdit, onEdit,
@@ -49,6 +50,7 @@ export function CardActions({
onComplete, onComplete,
onViewPlan, onViewPlan,
onApprovePlan, onApprovePlan,
onCodeReview,
}: CardActionsProps) { }: CardActionsProps) {
// Hide all actions when in selection mode // Hide all actions when in selection mode
if (isSelectionMode) { if (isSelectionMode) {
@@ -258,6 +260,24 @@ export function CardActions({
<span className="truncate">Refine</span> <span className="truncate">Refine</span>
</Button> </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 */} {/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */}
{feature.prUrl && onManualVerify ? ( {feature.prUrl && onManualVerify ? (
<Button <Button

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import {
GitBranch, GitBranch,
GitFork, GitFork,
ExternalLink, ExternalLink,
FileSearch,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -43,6 +44,7 @@ export interface RowActionHandlers {
onViewPlan?: () => void; onViewPlan?: () => void;
onApprovePlan?: () => void; onApprovePlan?: () => void;
onSpawnTask?: () => void; onSpawnTask?: () => void;
onCodeReview?: () => void;
} }
export interface RowActionsProps { export interface RowActionsProps {
@@ -479,6 +481,14 @@ export const RowActions = memo(function RowActions({
{handlers.onFollowUp && ( {handlers.onFollowUp && (
<MenuItem icon={Wand2} label="Refine" onClick={withClose(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 && ( {feature.prUrl && (
<MenuItem <MenuItem
icon={ExternalLink} icon={ExternalLink}
@@ -615,6 +625,7 @@ export function createRowActionHandlers(
viewPlan?: (id: string) => void; viewPlan?: (id: string) => void;
approvePlan?: (id: string) => void; approvePlan?: (id: string) => void;
spawnTask?: (id: string) => void; spawnTask?: (id: string) => void;
codeReview?: (id: string) => void;
} }
): RowActionHandlers { ): RowActionHandlers {
return { return {
@@ -631,5 +642,6 @@ export function createRowActionHandlers(
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined, onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined, onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(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; onViewPlan: (feature: Feature) => void;
onApprovePlan: (feature: Feature) => void; onApprovePlan: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void; onSpawnTask?: (feature: Feature) => void;
onCodeReview?: (feature: Feature) => void;
featuresWithContext: Set<string>; featuresWithContext: Set<string>;
runningAutoTasks: string[]; runningAutoTasks: string[];
onArchiveAllVerified: () => void; onArchiveAllVerified: () => void;
@@ -87,6 +88,7 @@ export function KanbanBoard({
onViewPlan, onViewPlan,
onApprovePlan, onApprovePlan,
onSpawnTask, onSpawnTask,
onCodeReview,
featuresWithContext, featuresWithContext,
runningAutoTasks, runningAutoTasks,
onArchiveAllVerified, onArchiveAllVerified,
@@ -326,6 +328,7 @@ export function KanbanBoard({
onViewPlan={() => onViewPlan(feature)} onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)} onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)} onSpawnTask={() => onSpawnTask?.(feature)}
onCodeReview={onCodeReview ? () => onCodeReview(feature) : undefined}
hasContext={featuresWithContext.has(feature.id)} hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)} isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey} shortcutKey={shortcutKey}

View File

@@ -23,6 +23,7 @@ import {
CursorSettingsTab, CursorSettingsTab,
CodexSettingsTab, CodexSettingsTab,
OpencodeSettingsTab, OpencodeSettingsTab,
CodeRabbitSettingsTab,
} from './settings-view/providers'; } from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers'; import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts'; import { PromptCustomizationSection } from './settings-view/prompts';
@@ -123,6 +124,8 @@ export function SettingsView() {
return <CodexSettingsTab />; return <CodexSettingsTab />;
case 'opencode-provider': case 'opencode-provider':
return <OpencodeSettingsTab />; return <OpencodeSettingsTab />;
case 'coderabbit-provider':
return <CodeRabbitSettingsTab />;
case 'providers': case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />; 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, Code2,
Webhook, Webhook,
} from 'lucide-react'; } 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'; import type { SettingsViewId } from '../hooks/use-settings-view';
export interface NavigationItem { export interface NavigationItem {
@@ -51,6 +57,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon }, { id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon }, { id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon }, { id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
{ id: 'coderabbit-provider', label: 'CodeRabbit', icon: CodeRabbitIcon },
], ],
}, },
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug }, { id: 'mcp-servers', label: 'MCP Servers', icon: Plug },

View File

@@ -8,6 +8,7 @@ export type SettingsViewId =
| 'cursor-provider' | 'cursor-provider'
| 'codex-provider' | 'codex-provider'
| 'opencode-provider' | 'opencode-provider'
| 'coderabbit-provider'
| 'mcp-servers' | 'mcp-servers'
| 'prompts' | 'prompts'
| 'model-defaults' | '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 { CursorSettingsTab } from './cursor-settings-tab';
export { CodexSettingsTab } from './codex-settings-tab'; export { CodexSettingsTab } from './codex-settings-tab';
export { OpencodeSettingsTab } from './opencode-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 { useAutoMode } from './use-auto-mode';
export { useBoardBackgroundSettings } from './use-board-background-settings'; 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 { useElectronAgent } from './use-electron-agent';
export { useGuidedPrompts } from './use-guided-prompts'; export { useGuidedPrompts } from './use-guided-prompts';
export { useKeyboardShortcuts } from './use-keyboard-shortcuts'; 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; user: string | null;
error?: string; 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; onInstallProgress?: (callback: (progress: any) => void) => () => void;
onAuthProgress?: (callback: (progress: any) => void) => () => void; onAuthProgress?: (callback: (progress: any) => void) => () => void;
} }

View File

@@ -35,11 +35,18 @@ import type {
NotificationsAPI, NotificationsAPI,
EventHistoryAPI, EventHistoryAPI,
} from './electron'; } 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 { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
import type { ModelId, ThinkingLevel, ReasoningEffort } from '@automaker/types';
import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
const logger = createLogger('HttpClient'); const logger = createLogger('HttpClient');
@@ -524,7 +531,8 @@ type EventType =
| 'dev-server:started' | 'dev-server:started'
| 'dev-server:output' | 'dev-server:output'
| 'dev-server:stopped' | 'dev-server:stopped'
| 'notification:created'; | 'notification:created'
| 'code_review:event';
/** /**
* Dev server log event payloads for WebSocket streaming * Dev server log event payloads for WebSocket streaming
@@ -1473,6 +1481,15 @@ export class HttpApiClient implements ElectronAPI {
error?: string; error?: string;
}> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }), }> => 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 // OpenCode CLI methods
getOpencodeStatus: (): Promise<{ getOpencodeStatus: (): Promise<{
success: boolean; success: boolean;
@@ -1560,6 +1577,41 @@ export class HttpApiClient implements ElectronAPI {
error?: string; error?: string;
}> => this.post('/api/setup/opencode/cache/clear'), }> => 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) => { onInstallProgress: (callback: (progress: unknown) => void) => {
return this.subscribeToEvent('agent:stream', callback); 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 API
context = { context = {
describeImage: ( 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:started'
| 'dev-server:output' | 'dev-server:output'
| 'dev-server:stopped' | 'dev-server:stopped'
| 'notification:created'; | 'notification:created'
| 'code_review:event';
export type EventCallback = (type: EventType, payload: unknown) => void; 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 // Worktree and PR types
export type { PRState, WorktreePRInfo } from './worktree.js'; export type { PRState, WorktreePRInfo } from './worktree.js';
export { PR_STATES, validatePRState } 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; google: string;
/** OpenAI API key (for compatibility or alternative providers) */ /** OpenAI API key (for compatibility or alternative providers) */
openai: string; openai: string;
/** CodeRabbit API key (for AI-powered code reviews) */
coderabbit: string;
}; };
} }
@@ -905,6 +907,7 @@ export const DEFAULT_CREDENTIALS: Credentials = {
anthropic: '', anthropic: '',
google: '', google: '',
openai: '', openai: '',
coderabbit: '',
}, },
}; };