mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: add CodeRabbit integration for AI-powered code reviews
This commit introduces the CodeRabbit service and its associated routes, enabling users to trigger, manage, and check the status of code reviews through a new API. Key features include: - New routes for triggering code reviews, checking status, and stopping reviews. - Integration with the CodeRabbit CLI for authentication and status checks. - UI components for displaying code review results and settings management. - Unit tests for the new code review functionality to ensure reliability. This enhancement aims to streamline the code review process and leverage AI capabilities for improved code quality.
This commit is contained in:
@@ -83,6 +83,8 @@ import { createNotificationsRoutes } from './routes/notifications/index.js';
|
||||
import { getNotificationService } from './services/notification-service.js';
|
||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||
import { getEventHistoryService } from './services/event-history-service.js';
|
||||
import { createCodeReviewRoutes } from './routes/code-review/index.js';
|
||||
import { CodeReviewService } from './services/code-review-service.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -209,6 +211,7 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
|
||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
const codeReviewService = new CodeReviewService(events, settingsService);
|
||||
|
||||
// Initialize DevServerService with event emitter for real-time log streaming
|
||||
const devServerService = getDevServerService();
|
||||
@@ -300,6 +303,7 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
||||
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
||||
app.use('/api/code-review', createCodeReviewRoutes(codeReviewService));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface UnifiedCliDetection {
|
||||
claude?: CliDetectionResult;
|
||||
codex?: CliDetectionResult;
|
||||
cursor?: CliDetectionResult;
|
||||
coderabbit?: CliDetectionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +77,16 @@ const CLI_CONFIGS = {
|
||||
win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex',
|
||||
},
|
||||
},
|
||||
coderabbit: {
|
||||
name: 'CodeRabbit CLI',
|
||||
commands: ['coderabbit', 'cr'],
|
||||
versionArgs: ['--version'],
|
||||
installCommands: {
|
||||
darwin: 'npm install -g coderabbit',
|
||||
linux: 'npm install -g coderabbit',
|
||||
win32: 'npm install -g coderabbit',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -230,6 +241,8 @@ export async function checkCliAuth(
|
||||
return await checkCodexAuth(command);
|
||||
case 'cursor':
|
||||
return await checkCursorAuth(command);
|
||||
case 'coderabbit':
|
||||
return await checkCodeRabbitAuth(command);
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
@@ -355,6 +368,64 @@ async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'no
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check CodeRabbit CLI authentication
|
||||
*
|
||||
* Expected output when authenticated:
|
||||
* ```
|
||||
* CodeRabbit CLI Status
|
||||
* ✅ Authentication: Logged in
|
||||
* User Information:
|
||||
* 👤 Name: ...
|
||||
* ```
|
||||
*/
|
||||
async function checkCodeRabbitAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
|
||||
// Check for environment variable
|
||||
if (process.env.CODERABBIT_API_KEY) {
|
||||
return 'api_key';
|
||||
}
|
||||
|
||||
// Try running auth status command
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, ['auth', 'status'], {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000, // Increased timeout for slower systems
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const output = stdout + stderr;
|
||||
|
||||
// Check for positive authentication indicators in output
|
||||
const isAuthenticated =
|
||||
code === 0 &&
|
||||
(output.includes('Logged in') || output.includes('logged in')) &&
|
||||
!output.toLowerCase().includes('not logged in') &&
|
||||
!output.toLowerCase().includes('not authenticated');
|
||||
|
||||
if (isAuthenticated) {
|
||||
resolve('cli');
|
||||
} else {
|
||||
resolve('none');
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
resolve('none');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installation instructions for a provider
|
||||
*/
|
||||
|
||||
78
apps/server/src/routes/code-review/common.ts
Normal file
78
apps/server/src/routes/code-review/common.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
apps/server/src/routes/code-review/index.ts
Normal file
40
apps/server/src/routes/code-review/index.ts
Normal 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;
|
||||
}
|
||||
38
apps/server/src/routes/code-review/routes/providers.ts
Normal file
38
apps/server/src/routes/code-review/routes/providers.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
32
apps/server/src/routes/code-review/routes/status.ts
Normal file
32
apps/server/src/routes/code-review/routes/status.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
54
apps/server/src/routes/code-review/routes/stop.ts
Normal file
54
apps/server/src/routes/code-review/routes/stop.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
188
apps/server/src/routes/code-review/routes/trigger.ts
Normal file
188
apps/server/src/routes/code-review/routes/trigger.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createClaudeStatusHandler } from './routes/claude-status.js';
|
||||
import { createInstallClaudeHandler } from './routes/install-claude.js';
|
||||
import { createAuthClaudeHandler } from './routes/auth-claude.js';
|
||||
@@ -12,6 +13,10 @@ import { createApiKeysHandler } from './routes/api-keys.js';
|
||||
import { createPlatformHandler } from './routes/platform.js';
|
||||
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
|
||||
import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js';
|
||||
import { createVerifyCodeRabbitAuthHandler } from './routes/verify-coderabbit-auth.js';
|
||||
import { createCodeRabbitStatusHandler } from './routes/coderabbit-status.js';
|
||||
import { createAuthCodeRabbitHandler } from './routes/auth-coderabbit.js';
|
||||
import { createDeauthCodeRabbitHandler } from './routes/deauth-coderabbit.js';
|
||||
import { createGhStatusHandler } from './routes/gh-status.js';
|
||||
import { createCursorStatusHandler } from './routes/cursor-status.js';
|
||||
import { createCodexStatusHandler } from './routes/codex-status.js';
|
||||
@@ -44,6 +49,9 @@ import {
|
||||
export function createSetupRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Unified CLI status endpoint
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
router.get('/claude-status', createClaudeStatusHandler());
|
||||
router.post('/install-claude', createInstallClaudeHandler());
|
||||
router.post('/auth-claude', createAuthClaudeHandler());
|
||||
@@ -54,6 +62,7 @@ export function createSetupRoutes(): Router {
|
||||
router.get('/platform', createPlatformHandler());
|
||||
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
|
||||
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
|
||||
router.post('/verify-coderabbit-auth', createVerifyCodeRabbitAuthHandler());
|
||||
router.get('/gh-status', createGhStatusHandler());
|
||||
|
||||
// Cursor CLI routes
|
||||
@@ -72,6 +81,11 @@ export function createSetupRoutes(): Router {
|
||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
||||
|
||||
// CodeRabbit CLI routes
|
||||
router.get('/coderabbit-status', createCodeRabbitStatusHandler());
|
||||
router.post('/auth-coderabbit', createAuthCodeRabbitHandler());
|
||||
router.post('/deauth-coderabbit', createDeauthCodeRabbitHandler());
|
||||
|
||||
// OpenCode Dynamic Model Discovery routes
|
||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
||||
|
||||
80
apps/server/src/routes/setup/routes/auth-coderabbit.ts
Normal file
80
apps/server/src/routes/setup/routes/auth-coderabbit.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
240
apps/server/src/routes/setup/routes/coderabbit-status.ts
Normal file
240
apps/server/src/routes/setup/routes/coderabbit-status.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
113
apps/server/src/routes/setup/routes/deauth-coderabbit.ts
Normal file
113
apps/server/src/routes/setup/routes/deauth-coderabbit.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
249
apps/server/src/routes/setup/routes/status.ts
Normal file
249
apps/server/src/routes/setup/routes/status.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
163
apps/server/src/routes/setup/routes/verify-coderabbit-auth.ts
Normal file
163
apps/server/src/routes/setup/routes/verify-coderabbit-auth.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
1244
apps/server/src/services/code-review-service.ts
Normal file
1244
apps/server/src/services/code-review-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -420,6 +420,7 @@ export class SettingsService {
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
google: { configured: boolean; masked: string };
|
||||
openai: { configured: boolean; masked: string };
|
||||
coderabbit: { configured: boolean; masked: string };
|
||||
}> {
|
||||
const credentials = await this.getCredentials();
|
||||
|
||||
@@ -441,6 +442,10 @@ export class SettingsService {
|
||||
configured: !!credentials.apiKeys.openai,
|
||||
masked: maskKey(credentials.apiKeys.openai),
|
||||
},
|
||||
coderabbit: {
|
||||
configured: !!credentials.apiKeys.coderabbit,
|
||||
masked: maskKey(credentials.apiKeys.coderabbit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -658,12 +663,14 @@ export class SettingsService {
|
||||
anthropic?: string;
|
||||
google?: string;
|
||||
openai?: string;
|
||||
coderabbit?: string;
|
||||
};
|
||||
await this.updateCredentials({
|
||||
apiKeys: {
|
||||
anthropic: apiKeys.anthropic || '',
|
||||
google: apiKeys.google || '',
|
||||
openai: apiKeys.openai || '',
|
||||
coderabbit: apiKeys.coderabbit || '',
|
||||
},
|
||||
});
|
||||
migratedCredentials = true;
|
||||
|
||||
196
apps/server/tests/unit/routes/code-review/providers.test.ts
Normal file
196
apps/server/tests/unit/routes/code-review/providers.test.ts
Normal 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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
apps/server/tests/unit/routes/code-review/status.test.ts
Normal file
109
apps/server/tests/unit/routes/code-review/status.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
129
apps/server/tests/unit/routes/code-review/stop.test.ts
Normal file
129
apps/server/tests/unit/routes/code-review/stop.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
384
apps/server/tests/unit/routes/code-review/trigger.test.ts
Normal file
384
apps/server/tests/unit/routes/code-review/trigger.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1048
apps/server/tests/unit/services/code-review-service.test.ts
Normal file
1048
apps/server/tests/unit/services/code-review-service.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
755
apps/ui/src/components/dialogs/code-review-dialog.tsx
Normal file
755
apps/ui/src/components/dialogs/code-review-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export { BoardBackgroundModal } from './board-background-modal';
|
||||
export { CodeReviewDialog } from './code-review-dialog';
|
||||
export type { CodeReviewDialogProps } from './code-review-dialog';
|
||||
export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-dialog';
|
||||
export { DeleteSessionDialog } from './delete-session-dialog';
|
||||
export { FileBrowserDialog } from './file-browser-dialog';
|
||||
|
||||
@@ -19,6 +19,7 @@ const PROVIDER_ICON_KEYS = {
|
||||
minimax: 'minimax',
|
||||
glm: 'glm',
|
||||
bigpickle: 'bigpickle',
|
||||
coderabbit: 'coderabbit',
|
||||
} as const;
|
||||
|
||||
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
||||
@@ -113,6 +114,12 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
||||
path: 'M8 4c-2.21 0-4 1.79-4 4v8c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V8c0-2.21-1.79-4-4-4H8zm0 2h8c1.103 0 2 .897 2 2v8c0 1.103-.897 2-2 2H8c-1.103 0-2-.897-2-2V8c0-1.103.897-2 2-2zm2 3a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2zm-4 4a1 1 0 100 2 1 1 0 000-2zm4 0a1 1 0 100 2 1 1 0 000-2z',
|
||||
fill: '#4ADE80',
|
||||
},
|
||||
coderabbit: {
|
||||
viewBox: '0 0 24 24',
|
||||
// CodeRabbit logo - rabbit/bunny icon
|
||||
path: 'M18 4a2 2 0 0 0-2-2c-.9 0-1.7.6-1.9 1.5l-.3 1.1c-.2.6-.7 1-1.3 1.2L12 6l-.5-.2c-.6-.2-1.1-.6-1.3-1.2l-.3-1.1C9.7 2.6 8.9 2 8 2a2 2 0 0 0-2 2c0 .7.4 1.4 1 1.7V7c0 1.1.9 2 2 2h1v2H8.5C6 11 4 13 4 15.5V18c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-2.5c0-2.5-2-4.5-4.5-4.5H14V9h1c1.1 0 2-.9 2-2V5.7c.6-.3 1-1 1-1.7zm-8 9h4c1.1 0 2 .9 2 2v1H8v-1c0-1.1.9-2 2-2z',
|
||||
fill: '#FF6B35',
|
||||
},
|
||||
};
|
||||
|
||||
export interface ProviderIconProps extends Omit<SVGProps<SVGSVGElement>, 'viewBox'> {
|
||||
@@ -178,6 +185,10 @@ export function OpenCodeIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.opencode} {...props} />;
|
||||
}
|
||||
|
||||
export function CodeRabbitIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.coderabbit} {...props} />;
|
||||
}
|
||||
|
||||
export function DeepSeekIcon({
|
||||
className,
|
||||
title,
|
||||
@@ -569,6 +580,7 @@ export function getProviderIconForModel(
|
||||
minimax: MiniMaxIcon,
|
||||
glm: GlmIcon,
|
||||
bigpickle: BigPickleIcon,
|
||||
coderabbit: CodeRabbitIcon,
|
||||
};
|
||||
|
||||
return iconMap[iconKey] || AnthropicIcon;
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
FollowUpDialog,
|
||||
PlanApprovalDialog,
|
||||
} from './board-view/dialogs';
|
||||
import { CodeReviewDialog } from '@/components/dialogs/code-review-dialog';
|
||||
import { useCodeReview } from '@/hooks/use-code-review';
|
||||
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
||||
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
|
||||
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
|
||||
@@ -167,6 +169,11 @@ export function BoardView() {
|
||||
// Pipeline settings dialog state
|
||||
const [showPipelineSettings, setShowPipelineSettings] = useState(false);
|
||||
|
||||
// Code review state
|
||||
const [showCodeReviewDialog, setShowCodeReviewDialog] = useState(false);
|
||||
const [codeReviewFeature, setCodeReviewFeature] = useState<Feature | null>(null);
|
||||
const codeReview = useCodeReview();
|
||||
|
||||
// Follow-up state hook
|
||||
const {
|
||||
showFollowUpDialog,
|
||||
@@ -1373,6 +1380,44 @@ export function BoardView() {
|
||||
[currentProject, setPendingPlanApproval]
|
||||
);
|
||||
|
||||
// Handle opening code review for a feature
|
||||
const handleCodeReview = useCallback(
|
||||
async (feature: Feature) => {
|
||||
if (!feature.branchName) {
|
||||
toast.error('Cannot review code', {
|
||||
description: 'Feature has no associated branch',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the worktree for this feature's branch
|
||||
const featureWorktree = worktrees.find((w) => w.branch === feature.branchName);
|
||||
const worktreePath = featureWorktree?.path;
|
||||
|
||||
if (!worktreePath) {
|
||||
toast.error('Cannot review code', {
|
||||
description: 'No worktree found for this feature. Create a worktree first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCodeReviewFeature(feature);
|
||||
setShowCodeReviewDialog(true);
|
||||
|
||||
// Trigger the code review for the feature's worktree
|
||||
// Don't pass baseRef - let the backend auto-detect the base branch for worktrees
|
||||
try {
|
||||
await codeReview.triggerReview({
|
||||
projectPath: worktreePath,
|
||||
// baseRef is omitted - backend will detect main/master for worktrees
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to trigger code review:', error);
|
||||
}
|
||||
},
|
||||
[codeReview, worktrees]
|
||||
);
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center" data-testid="board-view-no-project">
|
||||
@@ -1485,6 +1530,7 @@ export function BoardView() {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
},
|
||||
onCodeReview: handleCodeReview,
|
||||
}}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
pipelineConfig={pipelineConfig}
|
||||
@@ -1528,6 +1574,7 @@ export function BoardView() {
|
||||
setSpawnParentFeature(feature);
|
||||
setShowAddDialog(true);
|
||||
}}
|
||||
onCodeReview={handleCodeReview}
|
||||
featuresWithContext={featuresWithContext}
|
||||
runningAutoTasks={runningAutoTasks}
|
||||
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
|
||||
@@ -1749,6 +1796,26 @@ export function BoardView() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Code Review Dialog */}
|
||||
<CodeReviewDialog
|
||||
open={showCodeReviewDialog}
|
||||
onOpenChange={(open) => {
|
||||
setShowCodeReviewDialog(open);
|
||||
if (!open) {
|
||||
setCodeReviewFeature(null);
|
||||
codeReview.clearReview();
|
||||
}
|
||||
}}
|
||||
review={codeReview.review}
|
||||
loading={codeReview.reviewing}
|
||||
error={codeReview.error}
|
||||
onRetry={() => {
|
||||
if (codeReviewFeature) {
|
||||
handleCodeReview(codeReviewFeature);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Create Worktree Dialog */}
|
||||
<CreateWorktreeDialog
|
||||
open={showCreateWorktreeDialog}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { Feature } from '@/store/app-store';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Edit,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
Eye,
|
||||
Wand2,
|
||||
Archive,
|
||||
FileSearch,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CardActionsProps {
|
||||
@@ -30,12 +30,13 @@ interface CardActionsProps {
|
||||
onComplete?: () => void;
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
onCodeReview?: () => void;
|
||||
}
|
||||
|
||||
export function CardActions({
|
||||
feature,
|
||||
isCurrentAutoTask,
|
||||
hasContext,
|
||||
hasContext: _hasContext,
|
||||
shortcutKey,
|
||||
isSelectionMode = false,
|
||||
onEdit,
|
||||
@@ -49,6 +50,7 @@ export function CardActions({
|
||||
onComplete,
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
onCodeReview,
|
||||
}: CardActionsProps) {
|
||||
// Hide all actions when in selection mode
|
||||
if (isSelectionMode) {
|
||||
@@ -258,6 +260,24 @@ export function CardActions({
|
||||
<span className="truncate">Refine</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Code Review button - analyzes code for best practices */}
|
||||
{onCodeReview && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[11px] px-2.5 min-w-[44px] gap-1.5 hover:bg-blue-500/10 hover:text-blue-600 hover:border-blue-500/30 dark:hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCodeReview();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`code-review-${feature.id}`}
|
||||
aria-label="Start code review for this feature"
|
||||
>
|
||||
<FileSearch className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
<span className="sr-only sm:not-sr-only">Review</span>
|
||||
</Button>
|
||||
)}
|
||||
{/* Show Verify button if PR was created (changes are committed), otherwise show Mark as Verified button */}
|
||||
{feature.prUrl && onManualVerify ? (
|
||||
<Button
|
||||
|
||||
@@ -51,6 +51,7 @@ interface KanbanCardProps {
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
onSpawnTask?: () => void;
|
||||
onCodeReview?: () => void;
|
||||
hasContext?: boolean;
|
||||
isCurrentAutoTask?: boolean;
|
||||
shortcutKey?: string;
|
||||
@@ -84,6 +85,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
onSpawnTask,
|
||||
onCodeReview,
|
||||
hasContext,
|
||||
isCurrentAutoTask,
|
||||
shortcutKey,
|
||||
@@ -238,6 +240,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
onComplete={onComplete}
|
||||
onViewPlan={onViewPlan}
|
||||
onApprovePlan={onApprovePlan}
|
||||
onCodeReview={onCodeReview}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface ListViewActionHandlers {
|
||||
onViewPlan?: (feature: Feature) => void;
|
||||
onApprovePlan?: (feature: Feature) => void;
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onCodeReview?: (feature: Feature) => void;
|
||||
}
|
||||
|
||||
export interface ListViewProps {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GitBranch,
|
||||
GitFork,
|
||||
ExternalLink,
|
||||
FileSearch,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -43,6 +44,7 @@ export interface RowActionHandlers {
|
||||
onViewPlan?: () => void;
|
||||
onApprovePlan?: () => void;
|
||||
onSpawnTask?: () => void;
|
||||
onCodeReview?: () => void;
|
||||
}
|
||||
|
||||
export interface RowActionsProps {
|
||||
@@ -479,6 +481,14 @@ export const RowActions = memo(function RowActions({
|
||||
{handlers.onFollowUp && (
|
||||
<MenuItem icon={Wand2} label="Refine" onClick={withClose(handlers.onFollowUp)} />
|
||||
)}
|
||||
{handlers.onCodeReview && (
|
||||
<MenuItem
|
||||
icon={FileSearch}
|
||||
label="Code Review"
|
||||
onClick={withClose(handlers.onCodeReview)}
|
||||
variant="primary"
|
||||
/>
|
||||
)}
|
||||
{feature.prUrl && (
|
||||
<MenuItem
|
||||
icon={ExternalLink}
|
||||
@@ -615,6 +625,7 @@ export function createRowActionHandlers(
|
||||
viewPlan?: (id: string) => void;
|
||||
approvePlan?: (id: string) => void;
|
||||
spawnTask?: (id: string) => void;
|
||||
codeReview?: (id: string) => void;
|
||||
}
|
||||
): RowActionHandlers {
|
||||
return {
|
||||
@@ -631,5 +642,6 @@ export function createRowActionHandlers(
|
||||
onViewPlan: actions.viewPlan ? () => actions.viewPlan!(featureId) : undefined,
|
||||
onApprovePlan: actions.approvePlan ? () => actions.approvePlan!(featureId) : undefined,
|
||||
onSpawnTask: actions.spawnTask ? () => actions.spawnTask!(featureId) : undefined,
|
||||
onCodeReview: actions.codeReview ? () => actions.codeReview!(featureId) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ interface KanbanBoardProps {
|
||||
onViewPlan: (feature: Feature) => void;
|
||||
onApprovePlan: (feature: Feature) => void;
|
||||
onSpawnTask?: (feature: Feature) => void;
|
||||
onCodeReview?: (feature: Feature) => void;
|
||||
featuresWithContext: Set<string>;
|
||||
runningAutoTasks: string[];
|
||||
onArchiveAllVerified: () => void;
|
||||
@@ -87,6 +88,7 @@ export function KanbanBoard({
|
||||
onViewPlan,
|
||||
onApprovePlan,
|
||||
onSpawnTask,
|
||||
onCodeReview,
|
||||
featuresWithContext,
|
||||
runningAutoTasks,
|
||||
onArchiveAllVerified,
|
||||
@@ -326,6 +328,7 @@ export function KanbanBoard({
|
||||
onViewPlan={() => onViewPlan(feature)}
|
||||
onApprovePlan={() => onApprovePlan(feature)}
|
||||
onSpawnTask={() => onSpawnTask?.(feature)}
|
||||
onCodeReview={onCodeReview ? () => onCodeReview(feature) : undefined}
|
||||
hasContext={featuresWithContext.has(feature.id)}
|
||||
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
|
||||
shortcutKey={shortcutKey}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
CursorSettingsTab,
|
||||
CodexSettingsTab,
|
||||
OpencodeSettingsTab,
|
||||
CodeRabbitSettingsTab,
|
||||
} from './settings-view/providers';
|
||||
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||
import { PromptCustomizationSection } from './settings-view/prompts';
|
||||
@@ -123,6 +124,8 @@ export function SettingsView() {
|
||||
return <CodexSettingsTab />;
|
||||
case 'opencode-provider':
|
||||
return <OpencodeSettingsTab />;
|
||||
case 'coderabbit-provider':
|
||||
return <CodeRabbitSettingsTab />;
|
||||
case 'providers':
|
||||
case 'claude': // Backwards compatibility - redirect to claude-provider
|
||||
return <ClaudeSettingsTab />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
Code2,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
import {
|
||||
AnthropicIcon,
|
||||
CursorIcon,
|
||||
OpenAIIcon,
|
||||
OpenCodeIcon,
|
||||
CodeRabbitIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||
|
||||
export interface NavigationItem {
|
||||
@@ -51,6 +57,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
||||
{ id: 'cursor-provider', label: 'Cursor', icon: CursorIcon },
|
||||
{ id: 'codex-provider', label: 'Codex', icon: OpenAIIcon },
|
||||
{ id: 'opencode-provider', label: 'OpenCode', icon: OpenCodeIcon },
|
||||
{ id: 'coderabbit-provider', label: 'CodeRabbit', icon: CodeRabbitIcon },
|
||||
],
|
||||
},
|
||||
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SettingsViewId =
|
||||
| 'cursor-provider'
|
||||
| 'codex-provider'
|
||||
| 'opencode-provider'
|
||||
| 'coderabbit-provider'
|
||||
| 'mcp-servers'
|
||||
| 'prompts'
|
||||
| 'model-defaults'
|
||||
|
||||
@@ -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;
|
||||
@@ -3,3 +3,4 @@ export { ClaudeSettingsTab } from './claude-settings-tab';
|
||||
export { CursorSettingsTab } from './cursor-settings-tab';
|
||||
export { CodexSettingsTab } from './codex-settings-tab';
|
||||
export { OpencodeSettingsTab } from './opencode-settings-tab';
|
||||
export { CodeRabbitSettingsTab } from './coderabbit-settings-tab';
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export { useAutoMode } from './use-auto-mode';
|
||||
export { useBoardBackgroundSettings } from './use-board-background-settings';
|
||||
export {
|
||||
useCodeReview,
|
||||
type TriggerReviewOptions,
|
||||
type ReviewProgress,
|
||||
type ReviewProviderStatus,
|
||||
type UseCodeReviewResult,
|
||||
} from './use-code-review';
|
||||
export { useElectronAgent } from './use-electron-agent';
|
||||
export { useGuidedPrompts } from './use-guided-prompts';
|
||||
export { useKeyboardShortcuts } from './use-keyboard-shortcuts';
|
||||
|
||||
400
apps/ui/src/hooks/use-code-review.ts
Normal file
400
apps/ui/src/hooks/use-code-review.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1438,6 +1438,37 @@ interface SetupAPI {
|
||||
user: string | null;
|
||||
error?: string;
|
||||
}>;
|
||||
getCodeRabbitStatus?: () => Promise<{
|
||||
success: boolean;
|
||||
installed?: boolean;
|
||||
version?: string;
|
||||
path?: string;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
npm?: string;
|
||||
};
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: 'oauth' | 'none';
|
||||
username?: string;
|
||||
email?: string;
|
||||
organization?: string;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
authCodeRabbit?: () => Promise<{
|
||||
success: boolean;
|
||||
requiresManualAuth?: boolean;
|
||||
command?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
deauthCodeRabbit?: () => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
onInstallProgress?: (callback: (progress: any) => void) => () => void;
|
||||
onAuthProgress?: (callback: (progress: any) => void) => () => void;
|
||||
}
|
||||
|
||||
@@ -35,11 +35,18 @@ import type {
|
||||
NotificationsAPI,
|
||||
EventHistoryAPI,
|
||||
} from './electron';
|
||||
import type { EventHistoryFilter } from '@automaker/types';
|
||||
import type {
|
||||
EventHistoryFilter,
|
||||
ModelId,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
CodeReviewCategory,
|
||||
CodeReviewEvent,
|
||||
CodeReviewResult,
|
||||
} from '@automaker/types';
|
||||
import type { Message, SessionListItem } from '@/types/electron';
|
||||
import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
||||
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
||||
import type { ModelId, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||
|
||||
const logger = createLogger('HttpClient');
|
||||
@@ -524,7 +531,8 @@ type EventType =
|
||||
| 'dev-server:started'
|
||||
| 'dev-server:output'
|
||||
| 'dev-server:stopped'
|
||||
| 'notification:created';
|
||||
| 'notification:created'
|
||||
| 'code_review:event';
|
||||
|
||||
/**
|
||||
* Dev server log event payloads for WebSocket streaming
|
||||
@@ -1473,6 +1481,15 @@ export class HttpApiClient implements ElectronAPI {
|
||||
error?: string;
|
||||
}> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }),
|
||||
|
||||
verifyCodeRabbitAuth: (
|
||||
authMethod: 'cli' | 'api_key',
|
||||
apiKey?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
error?: string;
|
||||
}> => this.post('/api/setup/verify-coderabbit-auth', { authMethod, apiKey }),
|
||||
|
||||
// OpenCode CLI methods
|
||||
getOpencodeStatus: (): Promise<{
|
||||
success: boolean;
|
||||
@@ -1560,6 +1577,41 @@ export class HttpApiClient implements ElectronAPI {
|
||||
error?: string;
|
||||
}> => this.post('/api/setup/opencode/cache/clear'),
|
||||
|
||||
// CodeRabbit CLI methods
|
||||
getCodeRabbitStatus: (): Promise<{
|
||||
success: boolean;
|
||||
installed?: boolean;
|
||||
version?: string;
|
||||
path?: string;
|
||||
recommendation?: string;
|
||||
installCommands?: {
|
||||
macos?: string;
|
||||
npm?: string;
|
||||
};
|
||||
auth?: {
|
||||
authenticated: boolean;
|
||||
method: 'oauth' | 'none';
|
||||
username?: string;
|
||||
email?: string;
|
||||
organization?: string;
|
||||
};
|
||||
error?: string;
|
||||
}> => this.get('/api/setup/coderabbit-status'),
|
||||
|
||||
authCodeRabbit: (): Promise<{
|
||||
success: boolean;
|
||||
requiresManualAuth?: boolean;
|
||||
command?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> => this.post('/api/setup/auth-coderabbit'),
|
||||
|
||||
deauthCodeRabbit: (): Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}> => this.post('/api/setup/deauth-coderabbit'),
|
||||
|
||||
onInstallProgress: (callback: (progress: unknown) => void) => {
|
||||
return this.subscribeToEvent('agent:stream', callback);
|
||||
},
|
||||
@@ -2309,6 +2361,81 @@ export class HttpApiClient implements ElectronAPI {
|
||||
},
|
||||
};
|
||||
|
||||
// Code Review API
|
||||
codeReview = {
|
||||
/**
|
||||
* Trigger a new code review on the specified project
|
||||
* @param projectPath - Path to the project to review
|
||||
* @param options - Optional configuration for the review
|
||||
* @returns Promise with success status and message
|
||||
*/
|
||||
trigger: (
|
||||
projectPath: string,
|
||||
options?: {
|
||||
/** Specific files to review (if empty, reviews git diff) */
|
||||
files?: string[];
|
||||
/** Git ref to compare against (default: HEAD~1) */
|
||||
baseRef?: string;
|
||||
/** Categories to focus on */
|
||||
categories?: CodeReviewCategory[];
|
||||
/** Whether to attempt auto-fixes for issues found */
|
||||
autoFix?: boolean;
|
||||
/** Model to use for the review */
|
||||
model?: ModelId;
|
||||
/** Thinking level for extended reasoning */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
): Promise<{ success: boolean; message?: string; error?: string }> =>
|
||||
this.post('/api/code-review/trigger', { projectPath, ...options }),
|
||||
|
||||
/**
|
||||
* Get the current code review status
|
||||
* @returns Promise with running status and project path
|
||||
*/
|
||||
status: (): Promise<{
|
||||
success: boolean;
|
||||
isRunning: boolean;
|
||||
projectPath?: string;
|
||||
error?: string;
|
||||
}> => this.get('/api/code-review/status'),
|
||||
|
||||
/**
|
||||
* Stop the currently running code review
|
||||
* @returns Promise with success status and message
|
||||
*/
|
||||
stop: (): Promise<{ success: boolean; message?: string; error?: string }> =>
|
||||
this.post('/api/code-review/stop', {}),
|
||||
|
||||
/**
|
||||
* Get available code review providers and their status
|
||||
* @param forceRefresh - Force refresh of cached provider status
|
||||
* @returns Promise with list of providers and recommended provider
|
||||
*/
|
||||
getProviders: (
|
||||
forceRefresh = false
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
providers?: Array<{
|
||||
provider: 'claude' | 'codex' | 'cursor';
|
||||
available: boolean;
|
||||
authenticated: boolean;
|
||||
version?: string;
|
||||
issues: string[];
|
||||
}>;
|
||||
recommended?: string | null;
|
||||
error?: string;
|
||||
}> => this.get(`/api/code-review/providers${forceRefresh ? '?refresh=true' : ''}`),
|
||||
|
||||
/**
|
||||
* Subscribe to code review events via WebSocket
|
||||
* @param callback - Function to call when a code review event is received
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
onEvent: (callback: (event: CodeReviewEvent) => void): (() => void) => {
|
||||
return this.subscribeToEvent('code_review:event', callback as EventCallback);
|
||||
},
|
||||
};
|
||||
|
||||
// Context API
|
||||
context = {
|
||||
describeImage: (
|
||||
|
||||
184
libs/types/src/code-review.ts
Normal file
184
libs/types/src/code-review.ts
Normal 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;
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export type EventType =
|
||||
| 'dev-server:started'
|
||||
| 'dev-server:output'
|
||||
| 'dev-server:stopped'
|
||||
| 'notification:created';
|
||||
| 'notification:created'
|
||||
| 'code_review:event';
|
||||
|
||||
export type EventCallback = (type: EventType, payload: unknown) => void;
|
||||
|
||||
@@ -296,3 +296,18 @@ export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-hist
|
||||
// Worktree and PR types
|
||||
export type { PRState, WorktreePRInfo } from './worktree.js';
|
||||
export { PR_STATES, validatePRState } from './worktree.js';
|
||||
|
||||
// Code review types
|
||||
export type {
|
||||
CodeReviewSeverity,
|
||||
CodeReviewCategory,
|
||||
CodeReviewVerdict,
|
||||
CodeReviewComment,
|
||||
CodeReviewSummary,
|
||||
CodeReviewResult,
|
||||
CodeReviewRequest,
|
||||
CodeReviewResponse,
|
||||
CodeReviewErrorResponse,
|
||||
CodeReviewEvent,
|
||||
StoredCodeReview,
|
||||
} from './code-review.js';
|
||||
|
||||
@@ -669,6 +669,8 @@ export interface Credentials {
|
||||
google: string;
|
||||
/** OpenAI API key (for compatibility or alternative providers) */
|
||||
openai: string;
|
||||
/** CodeRabbit API key (for AI-powered code reviews) */
|
||||
coderabbit: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -905,6 +907,7 @@ export const DEFAULT_CREDENTIALS: Credentials = {
|
||||
anthropic: '',
|
||||
google: '',
|
||||
openai: '',
|
||||
coderabbit: '',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user