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