mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
feat: add CodeRabbit integration for AI-powered code reviews
This commit introduces the CodeRabbit service and its associated routes, enabling users to trigger, manage, and check the status of code reviews through a new API. Key features include: - New routes for triggering code reviews, checking status, and stopping reviews. - Integration with the CodeRabbit CLI for authentication and status checks. - UI components for displaying code review results and settings management. - Unit tests for the new code review functionality to ensure reliability. This enhancement aims to streamline the code review process and leverage AI capabilities for improved code quality.
This commit is contained in:
@@ -83,6 +83,8 @@ import { createNotificationsRoutes } from './routes/notifications/index.js';
|
||||
import { getNotificationService } from './services/notification-service.js';
|
||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||
import { getEventHistoryService } from './services/event-history-service.js';
|
||||
import { createCodeReviewRoutes } from './routes/code-review/index.js';
|
||||
import { CodeReviewService } from './services/code-review-service.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -209,6 +211,7 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
|
||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
const codeReviewService = new CodeReviewService(events, settingsService);
|
||||
|
||||
// Initialize DevServerService with event emitter for real-time log streaming
|
||||
const devServerService = getDevServerService();
|
||||
@@ -300,6 +303,7 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
||||
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
||||
app.use('/api/code-review', createCodeReviewRoutes(codeReviewService));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface UnifiedCliDetection {
|
||||
claude?: CliDetectionResult;
|
||||
codex?: CliDetectionResult;
|
||||
cursor?: CliDetectionResult;
|
||||
coderabbit?: CliDetectionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +77,16 @@ const CLI_CONFIGS = {
|
||||
win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex',
|
||||
},
|
||||
},
|
||||
coderabbit: {
|
||||
name: 'CodeRabbit CLI',
|
||||
commands: ['coderabbit', 'cr'],
|
||||
versionArgs: ['--version'],
|
||||
installCommands: {
|
||||
darwin: 'npm install -g coderabbit',
|
||||
linux: 'npm install -g coderabbit',
|
||||
win32: 'npm install -g coderabbit',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -230,6 +241,8 @@ export async function checkCliAuth(
|
||||
return await checkCodexAuth(command);
|
||||
case 'cursor':
|
||||
return await checkCursorAuth(command);
|
||||
case 'coderabbit':
|
||||
return await checkCodeRabbitAuth(command);
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
@@ -355,6 +368,64 @@ async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'no
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check CodeRabbit CLI authentication
|
||||
*
|
||||
* Expected output when authenticated:
|
||||
* ```
|
||||
* CodeRabbit CLI Status
|
||||
* ✅ Authentication: Logged in
|
||||
* User Information:
|
||||
* 👤 Name: ...
|
||||
* ```
|
||||
*/
|
||||
async function checkCodeRabbitAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
|
||||
// Check for environment variable
|
||||
if (process.env.CODERABBIT_API_KEY) {
|
||||
return 'api_key';
|
||||
}
|
||||
|
||||
// Try running auth status command
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, ['auth', 'status'], {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000, // Increased timeout for slower systems
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const output = stdout + stderr;
|
||||
|
||||
// Check for positive authentication indicators in output
|
||||
const isAuthenticated =
|
||||
code === 0 &&
|
||||
(output.includes('Logged in') || output.includes('logged in')) &&
|
||||
!output.toLowerCase().includes('not logged in') &&
|
||||
!output.toLowerCase().includes('not authenticated');
|
||||
|
||||
if (isAuthenticated) {
|
||||
resolve('cli');
|
||||
} else {
|
||||
resolve('none');
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
resolve('none');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installation instructions for a provider
|
||||
*/
|
||||
|
||||
78
apps/server/src/routes/code-review/common.ts
Normal file
78
apps/server/src/routes/code-review/common.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Common utilities for code-review routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('CodeReview');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
/**
|
||||
* Review state interface
|
||||
*/
|
||||
interface ReviewState {
|
||||
isRunning: boolean;
|
||||
abortController: AbortController | null;
|
||||
projectPath: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared state for code review operations
|
||||
* Using an object to avoid mutable `let` exports which can cause issues in ES modules
|
||||
*/
|
||||
const reviewState: ReviewState = {
|
||||
isRunning: false,
|
||||
abortController: null,
|
||||
projectPath: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a review is currently running
|
||||
*/
|
||||
export function isRunning(): boolean {
|
||||
return reviewState.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current abort controller (for stopping reviews)
|
||||
*/
|
||||
export function getAbortController(): AbortController | null {
|
||||
return reviewState.abortController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current project path being reviewed
|
||||
*/
|
||||
export function getCurrentProjectPath(): string | null {
|
||||
return reviewState.projectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the running state for code review operations
|
||||
*/
|
||||
export function setRunningState(
|
||||
running: boolean,
|
||||
controller: AbortController | null = null,
|
||||
projectPath: string | null = null
|
||||
): void {
|
||||
reviewState.isRunning = running;
|
||||
reviewState.abortController = controller;
|
||||
reviewState.projectPath = projectPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current review status
|
||||
*/
|
||||
export function getReviewStatus(): {
|
||||
isRunning: boolean;
|
||||
projectPath: string | null;
|
||||
} {
|
||||
return {
|
||||
isRunning: reviewState.isRunning,
|
||||
projectPath: reviewState.projectPath,
|
||||
};
|
||||
}
|
||||
40
apps/server/src/routes/code-review/index.ts
Normal file
40
apps/server/src/routes/code-review/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Code Review routes - HTTP API for triggering and managing code reviews
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Triggering code reviews on projects
|
||||
* - Checking review status
|
||||
* - Stopping in-progress reviews
|
||||
*
|
||||
* Uses the CodeReviewService for actual review execution with AI providers.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { CodeReviewService } from '../../services/code-review-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createTriggerHandler } from './routes/trigger.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createProvidersHandler } from './routes/providers.js';
|
||||
|
||||
export function createCodeReviewRoutes(codeReviewService: CodeReviewService): Router {
|
||||
const router = Router();
|
||||
|
||||
// POST /trigger - Start a new code review
|
||||
router.post(
|
||||
'/trigger',
|
||||
validatePathParams('projectPath'),
|
||||
createTriggerHandler(codeReviewService)
|
||||
);
|
||||
|
||||
// GET /status - Get current review status
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
// POST /stop - Stop current review
|
||||
router.post('/stop', createStopHandler());
|
||||
|
||||
// GET /providers - Get available providers and their status
|
||||
router.get('/providers', createProvidersHandler(codeReviewService));
|
||||
|
||||
return router;
|
||||
}
|
||||
38
apps/server/src/routes/code-review/routes/providers.ts
Normal file
38
apps/server/src/routes/code-review/routes/providers.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* GET /providers endpoint - Get available code review providers
|
||||
*
|
||||
* Returns the status of all available AI providers that can be used for code reviews.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { CodeReviewService } from '../../../services/code-review-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('CodeReview');
|
||||
|
||||
export function createProvidersHandler(codeReviewService: CodeReviewService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.debug('========== /providers endpoint called ==========');
|
||||
|
||||
try {
|
||||
// Check if refresh is requested
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
|
||||
const providers = await codeReviewService.getProviderStatus(forceRefresh);
|
||||
const bestProvider = await codeReviewService.getBestProvider();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
providers,
|
||||
recommended: bestProvider,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Providers handler exception');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
32
apps/server/src/routes/code-review/routes/status.ts
Normal file
32
apps/server/src/routes/code-review/routes/status.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* GET /status endpoint - Get current code review status
|
||||
*
|
||||
* Returns whether a code review is currently running and which project.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getReviewStatus, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('CodeReview');
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
logger.debug('========== /status endpoint called ==========');
|
||||
|
||||
try {
|
||||
const status = getReviewStatus();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...status,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Status handler exception');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
54
apps/server/src/routes/code-review/routes/stop.ts
Normal file
54
apps/server/src/routes/code-review/routes/stop.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop the current code review
|
||||
*
|
||||
* Aborts any running code review operation.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
isRunning,
|
||||
getAbortController,
|
||||
setRunningState,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
} from '../common.js';
|
||||
|
||||
const logger = createLogger('CodeReview');
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
logger.info('========== /stop endpoint called ==========');
|
||||
|
||||
try {
|
||||
if (!isRunning()) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'No code review is currently running',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort the current operation
|
||||
const abortController = getAbortController();
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
logger.info('Code review aborted');
|
||||
}
|
||||
|
||||
// Reset state
|
||||
setRunningState(false, null, null);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Code review stopped',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Stop handler exception');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
188
apps/server/src/routes/code-review/routes/trigger.ts
Normal file
188
apps/server/src/routes/code-review/routes/trigger.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* POST /trigger endpoint - Trigger a code review
|
||||
*
|
||||
* Starts an asynchronous code review on the specified project.
|
||||
* Progress updates are streamed via WebSocket events.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { CodeReviewService } from '../../../services/code-review-service.js';
|
||||
import type { CodeReviewCategory, ThinkingLevel, ModelId } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { isRunning, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('CodeReview');
|
||||
|
||||
/**
|
||||
* Maximum number of files allowed per review request
|
||||
*/
|
||||
const MAX_FILES_PER_REQUEST = 100;
|
||||
|
||||
/**
|
||||
* Maximum length for baseRef parameter
|
||||
*/
|
||||
const MAX_BASE_REF_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* Valid categories for code review
|
||||
*/
|
||||
const VALID_CATEGORIES: CodeReviewCategory[] = [
|
||||
'tech_stack',
|
||||
'security',
|
||||
'code_quality',
|
||||
'implementation',
|
||||
'architecture',
|
||||
'performance',
|
||||
'testing',
|
||||
'documentation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Valid thinking levels
|
||||
*/
|
||||
const VALID_THINKING_LEVELS: ThinkingLevel[] = ['low', 'medium', 'high'];
|
||||
|
||||
interface TriggerRequestBody {
|
||||
projectPath: string;
|
||||
files?: string[];
|
||||
baseRef?: string;
|
||||
categories?: CodeReviewCategory[];
|
||||
autoFix?: boolean;
|
||||
model?: ModelId;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize the request body
|
||||
*/
|
||||
function validateRequestBody(body: TriggerRequestBody): { valid: boolean; error?: string } {
|
||||
const { files, baseRef, categories, autoFix, thinkingLevel } = body;
|
||||
|
||||
// Validate files array
|
||||
if (files !== undefined) {
|
||||
if (!Array.isArray(files)) {
|
||||
return { valid: false, error: 'files must be an array' };
|
||||
}
|
||||
if (files.length > MAX_FILES_PER_REQUEST) {
|
||||
return { valid: false, error: `Maximum ${MAX_FILES_PER_REQUEST} files allowed per request` };
|
||||
}
|
||||
for (const file of files) {
|
||||
if (typeof file !== 'string') {
|
||||
return { valid: false, error: 'Each file must be a string' };
|
||||
}
|
||||
if (file.length > 500) {
|
||||
return { valid: false, error: 'File path too long' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate baseRef
|
||||
if (baseRef !== undefined) {
|
||||
if (typeof baseRef !== 'string') {
|
||||
return { valid: false, error: 'baseRef must be a string' };
|
||||
}
|
||||
if (baseRef.length > MAX_BASE_REF_LENGTH) {
|
||||
return { valid: false, error: 'baseRef is too long' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate categories
|
||||
if (categories !== undefined) {
|
||||
if (!Array.isArray(categories)) {
|
||||
return { valid: false, error: 'categories must be an array' };
|
||||
}
|
||||
for (const category of categories) {
|
||||
if (!VALID_CATEGORIES.includes(category)) {
|
||||
return { valid: false, error: `Invalid category: ${category}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate autoFix
|
||||
if (autoFix !== undefined && typeof autoFix !== 'boolean') {
|
||||
return { valid: false, error: 'autoFix must be a boolean' };
|
||||
}
|
||||
|
||||
// Validate thinkingLevel
|
||||
if (thinkingLevel !== undefined) {
|
||||
if (!VALID_THINKING_LEVELS.includes(thinkingLevel)) {
|
||||
return { valid: false, error: `Invalid thinkingLevel: ${thinkingLevel}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export function createTriggerHandler(codeReviewService: CodeReviewService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
logger.info('========== /trigger endpoint called ==========');
|
||||
|
||||
try {
|
||||
const body = req.body as TriggerRequestBody;
|
||||
const { projectPath, files, baseRef, categories, autoFix, model, thinkingLevel } = body;
|
||||
|
||||
// Validate required parameters
|
||||
if (!projectPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY: Validate all input parameters
|
||||
const validation = validateRequestBody(body);
|
||||
if (!validation.valid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: validation.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a review is already running
|
||||
if (isRunning()) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: 'A code review is already in progress',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up abort controller for cancellation
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController, projectPath);
|
||||
|
||||
// Start the review in the background
|
||||
codeReviewService
|
||||
.executeReview({
|
||||
projectPath,
|
||||
files,
|
||||
baseRef,
|
||||
categories,
|
||||
autoFix,
|
||||
model,
|
||||
thinkingLevel,
|
||||
abortController,
|
||||
})
|
||||
.catch((error) => {
|
||||
logError(error, 'Code review failed');
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null, null);
|
||||
});
|
||||
|
||||
// Return immediate response
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Code review started',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Trigger handler exception');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createClaudeStatusHandler } from './routes/claude-status.js';
|
||||
import { createInstallClaudeHandler } from './routes/install-claude.js';
|
||||
import { createAuthClaudeHandler } from './routes/auth-claude.js';
|
||||
@@ -12,6 +13,10 @@ import { createApiKeysHandler } from './routes/api-keys.js';
|
||||
import { createPlatformHandler } from './routes/platform.js';
|
||||
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
|
||||
import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js';
|
||||
import { createVerifyCodeRabbitAuthHandler } from './routes/verify-coderabbit-auth.js';
|
||||
import { createCodeRabbitStatusHandler } from './routes/coderabbit-status.js';
|
||||
import { createAuthCodeRabbitHandler } from './routes/auth-coderabbit.js';
|
||||
import { createDeauthCodeRabbitHandler } from './routes/deauth-coderabbit.js';
|
||||
import { createGhStatusHandler } from './routes/gh-status.js';
|
||||
import { createCursorStatusHandler } from './routes/cursor-status.js';
|
||||
import { createCodexStatusHandler } from './routes/codex-status.js';
|
||||
@@ -44,6 +49,9 @@ import {
|
||||
export function createSetupRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Unified CLI status endpoint
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
router.get('/claude-status', createClaudeStatusHandler());
|
||||
router.post('/install-claude', createInstallClaudeHandler());
|
||||
router.post('/auth-claude', createAuthClaudeHandler());
|
||||
@@ -54,6 +62,7 @@ export function createSetupRoutes(): Router {
|
||||
router.get('/platform', createPlatformHandler());
|
||||
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
|
||||
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
|
||||
router.post('/verify-coderabbit-auth', createVerifyCodeRabbitAuthHandler());
|
||||
router.get('/gh-status', createGhStatusHandler());
|
||||
|
||||
// Cursor CLI routes
|
||||
@@ -72,6 +81,11 @@ export function createSetupRoutes(): Router {
|
||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
||||
|
||||
// CodeRabbit CLI routes
|
||||
router.get('/coderabbit-status', createCodeRabbitStatusHandler());
|
||||
router.post('/auth-coderabbit', createAuthCodeRabbitHandler());
|
||||
router.post('/deauth-coderabbit', createDeauthCodeRabbitHandler());
|
||||
|
||||
// OpenCode Dynamic Model Discovery routes
|
||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
||||
|
||||
80
apps/server/src/routes/setup/routes/auth-coderabbit.ts
Normal file
80
apps/server/src/routes/setup/routes/auth-coderabbit.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* POST /auth-coderabbit endpoint - Authenticate CodeRabbit CLI via OAuth
|
||||
*
|
||||
* CodeRabbit CLI requires interactive authentication:
|
||||
* 1. Run `cr auth login`
|
||||
* 2. Browser opens with OAuth flow
|
||||
* 3. After browser auth, CLI shows a token
|
||||
* 4. User must press Enter to confirm
|
||||
*
|
||||
* Since step 4 requires interactive input, we can't fully automate this.
|
||||
* Instead, we provide the command for the user to run manually.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { execSync } from 'child_process';
|
||||
import { logError, getErrorMessage } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Find the CodeRabbit CLI command (coderabbit or cr)
|
||||
*/
|
||||
function findCodeRabbitCommand(): string | null {
|
||||
const commands = ['coderabbit', 'cr'];
|
||||
for (const command of commands) {
|
||||
try {
|
||||
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
|
||||
const result = execSync(`${whichCommand} ${command}`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
}).trim();
|
||||
if (result) {
|
||||
return result.split('\n')[0];
|
||||
}
|
||||
} catch {
|
||||
// Command not found, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createAuthCodeRabbitHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Remove the disconnected marker file to reconnect the app to the CLI
|
||||
const markerPath = path.join(process.cwd(), '.automaker', '.coderabbit-disconnected');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
fs.unlinkSync(markerPath);
|
||||
}
|
||||
|
||||
// Find CodeRabbit CLI
|
||||
const cliPath = findCodeRabbitCommand();
|
||||
if (!cliPath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'CodeRabbit CLI is not installed. Please install it first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// CodeRabbit CLI requires interactive input (pressing Enter after OAuth)
|
||||
// We can't automate this, so we return the command for the user to run
|
||||
const command = cliPath.includes('coderabbit') ? 'coderabbit auth login' : 'cr auth login';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
requiresManualAuth: true,
|
||||
command,
|
||||
message: `Please run "${command}" in your terminal to authenticate. After completing OAuth in your browser, press Enter in the terminal to confirm.`,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Auth CodeRabbit failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
message: 'Failed to initiate CodeRabbit authentication',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
240
apps/server/src/routes/setup/routes/coderabbit-status.ts
Normal file
240
apps/server/src/routes/setup/routes/coderabbit-status.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* GET /coderabbit-status endpoint - Get CodeRabbit CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.coderabbit-disconnected';
|
||||
|
||||
function isCodeRabbitDisconnectedFromApp(): boolean {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
||||
return fs.existsSync(markerPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the CodeRabbit CLI command (coderabbit or cr)
|
||||
*/
|
||||
function findCodeRabbitCommand(): string | null {
|
||||
const commands = ['coderabbit', 'cr'];
|
||||
for (const command of commands) {
|
||||
try {
|
||||
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
|
||||
const result = execSync(`${whichCommand} ${command}`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
}).trim();
|
||||
if (result) {
|
||||
return result.split('\n')[0];
|
||||
}
|
||||
} catch {
|
||||
// Command not found, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CodeRabbit CLI version
|
||||
*/
|
||||
async function getCodeRabbitVersion(command: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, ['--version'], {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0 && stdout) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface CodeRabbitAuthInfo {
|
||||
authenticated: boolean;
|
||||
method: 'oauth' | 'none';
|
||||
username?: string;
|
||||
email?: string;
|
||||
organization?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check CodeRabbit CLI authentication status
|
||||
* Parses output like:
|
||||
* ```
|
||||
* CodeRabbit CLI Status
|
||||
* ✅ Authentication: Logged in
|
||||
* User Information:
|
||||
* 👤 Name: Kacper
|
||||
* 📧 Email: kacperlachowiczwp.pl@wp.pl
|
||||
* 🔧 Username: Shironex
|
||||
* Organization Information:
|
||||
* 🏢 Name: Anime-World-SPZOO
|
||||
* ```
|
||||
*/
|
||||
async function getCodeRabbitAuthStatus(command: string): Promise<CodeRabbitAuthInfo> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, ['auth', 'status'], {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const output = stdout + stderr;
|
||||
|
||||
// Check for "Logged in" in Authentication line
|
||||
const isAuthenticated =
|
||||
code === 0 &&
|
||||
(output.includes('Logged in') || output.includes('logged in')) &&
|
||||
!output.toLowerCase().includes('not logged in');
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Parse the structured output format
|
||||
// Username: look for "Username: <value>" line
|
||||
const usernameMatch = output.match(/Username:\s*(\S+)/i);
|
||||
// Email: look for "Email: <value>" line
|
||||
const emailMatch = output.match(/Email:\s*(\S+@\S+)/i);
|
||||
// Organization: look for "Name: <value>" under Organization Information
|
||||
// The org name appears after "Organization Information:" section
|
||||
const orgSection = output.split(/Organization Information:/i)[1];
|
||||
const orgMatch = orgSection?.match(/Name:\s*(.+?)(?:\n|$)/i);
|
||||
|
||||
resolve({
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
username: usernameMatch?.[1]?.trim(),
|
||||
email: emailMatch?.[1]?.trim(),
|
||||
organization: orgMatch?.[1]?.trim(),
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/coderabbit-status
|
||||
* Returns CodeRabbit CLI installation and authentication status
|
||||
*/
|
||||
export function createCodeRabbitStatusHandler() {
|
||||
const installCommand = 'npm install -g coderabbit';
|
||||
const loginCommand = 'coderabbit auth login';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if user has manually disconnected from the app
|
||||
if (isCodeRabbitDisconnectedFromApp()) {
|
||||
res.json({
|
||||
success: true,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
},
|
||||
recommendation: 'CodeRabbit CLI is disconnected. Click Sign In to reconnect.',
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find CodeRabbit CLI
|
||||
const cliPath = findCodeRabbitCommand();
|
||||
|
||||
if (!cliPath) {
|
||||
res.json({
|
||||
success: true,
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
},
|
||||
recommendation: 'Install CodeRabbit CLI to enable AI-powered code reviews.',
|
||||
installCommand,
|
||||
loginCommand,
|
||||
installCommands: {
|
||||
macos: 'curl -fsSL https://coderabbit.ai/install | bash',
|
||||
npm: installCommand,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get version
|
||||
const version = await getCodeRabbitVersion(cliPath);
|
||||
|
||||
// Get auth status
|
||||
const authStatus = await getCodeRabbitAuthStatus(cliPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: true,
|
||||
version,
|
||||
path: cliPath,
|
||||
auth: authStatus,
|
||||
recommendation: authStatus.authenticated
|
||||
? undefined
|
||||
: 'Sign in to CodeRabbit to enable AI-powered code reviews.',
|
||||
installCommand,
|
||||
loginCommand,
|
||||
installCommands: {
|
||||
macos: 'curl -fsSL https://coderabbit.ai/install | bash',
|
||||
npm: installCommand,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get CodeRabbit status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
113
apps/server/src/routes/setup/routes/deauth-coderabbit.ts
Normal file
113
apps/server/src/routes/setup/routes/deauth-coderabbit.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* POST /deauth-coderabbit endpoint - Sign out from CodeRabbit CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { logError, getErrorMessage } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Find the CodeRabbit CLI command (coderabbit or cr)
|
||||
*/
|
||||
function findCodeRabbitCommand(): string | null {
|
||||
const commands = ['coderabbit', 'cr'];
|
||||
for (const command of commands) {
|
||||
try {
|
||||
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
|
||||
const result = execSync(`${whichCommand} ${command}`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
}).trim();
|
||||
if (result) {
|
||||
return result.split('\n')[0];
|
||||
}
|
||||
} catch {
|
||||
// Command not found, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createDeauthCodeRabbitHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Find CodeRabbit CLI
|
||||
const cliPath = findCodeRabbitCommand();
|
||||
|
||||
if (cliPath) {
|
||||
// Try to run the CLI logout command
|
||||
const logoutResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
|
||||
const child = spawn(cliPath, ['auth', 'logout'], {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
resolve({ success: false, error: stderr || 'Logout command failed' });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
resolve({ success: false, error: err.message });
|
||||
});
|
||||
});
|
||||
|
||||
if (!logoutResult.success) {
|
||||
// CLI logout failed, create marker file as fallback
|
||||
const automakerDir = path.join(process.cwd(), '.automaker');
|
||||
const markerPath = path.join(automakerDir, '.coderabbit-disconnected');
|
||||
|
||||
if (!fs.existsSync(automakerDir)) {
|
||||
fs.mkdirSync(automakerDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
markerPath,
|
||||
JSON.stringify({
|
||||
disconnectedAt: new Date().toISOString(),
|
||||
message: 'CodeRabbit CLI is disconnected from the app',
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// CLI not installed, just create marker file
|
||||
const automakerDir = path.join(process.cwd(), '.automaker');
|
||||
const markerPath = path.join(automakerDir, '.coderabbit-disconnected');
|
||||
|
||||
if (!fs.existsSync(automakerDir)) {
|
||||
fs.mkdirSync(automakerDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
markerPath,
|
||||
JSON.stringify({
|
||||
disconnectedAt: new Date().toISOString(),
|
||||
message: 'CodeRabbit CLI is disconnected from the app',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Successfully signed out from CodeRabbit CLI',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth CodeRabbit failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
message: 'Failed to sign out from CodeRabbit CLI',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
249
apps/server/src/routes/setup/routes/status.ts
Normal file
249
apps/server/src/routes/setup/routes/status.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* GET /status endpoint - Get unified CLI availability status
|
||||
*
|
||||
* Returns the installation and authentication status of all supported CLIs
|
||||
* in a single response. This is useful for quickly determining which
|
||||
* providers are available without making multiple API calls.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getClaudeStatus } from '../get-claude-status.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { CursorProvider } from '../../../providers/cursor-provider.js';
|
||||
import { CodexProvider } from '../../../providers/codex-provider.js';
|
||||
import { OpencodeProvider } from '../../../providers/opencode-provider.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Check if a CLI has been manually disconnected from the app
|
||||
*/
|
||||
function isCliDisconnected(cliName: string): boolean {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', `.${cliName}-disconnected`);
|
||||
return fs.existsSync(markerPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI status response for a single provider
|
||||
*/
|
||||
interface CliStatusResponse {
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
path: string | null;
|
||||
auth: {
|
||||
authenticated: boolean;
|
||||
method: string;
|
||||
};
|
||||
disconnected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified status response for all CLIs
|
||||
*/
|
||||
interface UnifiedStatusResponse {
|
||||
success: boolean;
|
||||
timestamp: string;
|
||||
clis: {
|
||||
claude: CliStatusResponse | null;
|
||||
cursor: CliStatusResponse | null;
|
||||
codex: CliStatusResponse | null;
|
||||
opencode: CliStatusResponse | null;
|
||||
};
|
||||
availableProviders: string[];
|
||||
hasAnyAuthenticated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed Claude CLI status
|
||||
*/
|
||||
async function getClaudeCliStatus(): Promise<CliStatusResponse> {
|
||||
const disconnected = isCliDisconnected('claude');
|
||||
|
||||
try {
|
||||
const status = await getClaudeStatus();
|
||||
return {
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: disconnected ? false : status.auth.authenticated,
|
||||
method: disconnected ? 'none' : status.auth.method,
|
||||
},
|
||||
disconnected,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: { authenticated: false, method: 'none' },
|
||||
disconnected,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed Cursor CLI status
|
||||
*/
|
||||
async function getCursorCliStatus(): Promise<CliStatusResponse> {
|
||||
const disconnected = isCliDisconnected('cursor');
|
||||
|
||||
try {
|
||||
const provider = new CursorProvider();
|
||||
const [installed, version, auth] = await Promise.all([
|
||||
provider.isInstalled(),
|
||||
provider.getVersion(),
|
||||
provider.checkAuth(),
|
||||
]);
|
||||
|
||||
const cliPath = installed ? provider.getCliPath() : null;
|
||||
|
||||
return {
|
||||
installed,
|
||||
version: version || null,
|
||||
path: cliPath,
|
||||
auth: {
|
||||
authenticated: disconnected ? false : auth.authenticated,
|
||||
method: disconnected ? 'none' : auth.method,
|
||||
},
|
||||
disconnected,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: { authenticated: false, method: 'none' },
|
||||
disconnected,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed Codex CLI status
|
||||
*/
|
||||
async function getCodexCliStatus(): Promise<CliStatusResponse> {
|
||||
const disconnected = isCliDisconnected('codex');
|
||||
|
||||
try {
|
||||
const provider = new CodexProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
|
||||
let authMethod = 'none';
|
||||
if (!disconnected && status.authenticated) {
|
||||
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||
}
|
||||
|
||||
return {
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: disconnected ? false : status.authenticated || false,
|
||||
method: authMethod,
|
||||
},
|
||||
disconnected,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: { authenticated: false, method: 'none' },
|
||||
disconnected,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed OpenCode CLI status
|
||||
*/
|
||||
async function getOpencodeCliStatus(): Promise<CliStatusResponse> {
|
||||
try {
|
||||
const provider = new OpencodeProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
|
||||
let authMethod = 'none';
|
||||
if (status.authenticated) {
|
||||
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||
}
|
||||
|
||||
return {
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: status.authenticated || false,
|
||||
method: authMethod,
|
||||
},
|
||||
disconnected: false, // OpenCode doesn't have disconnect feature
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: { authenticated: false, method: 'none' },
|
||||
disconnected: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/status
|
||||
* Returns unified CLI availability status for all providers
|
||||
*/
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Fetch all CLI statuses in parallel for performance
|
||||
const [claude, cursor, codex, opencode] = await Promise.all([
|
||||
getClaudeCliStatus(),
|
||||
getCursorCliStatus(),
|
||||
getCodexCliStatus(),
|
||||
getOpencodeCliStatus(),
|
||||
]);
|
||||
|
||||
// Determine which providers are available (installed and authenticated)
|
||||
const availableProviders: string[] = [];
|
||||
if (claude.installed && claude.auth.authenticated) {
|
||||
availableProviders.push('claude');
|
||||
}
|
||||
if (cursor.installed && cursor.auth.authenticated) {
|
||||
availableProviders.push('cursor');
|
||||
}
|
||||
if (codex.installed && codex.auth.authenticated) {
|
||||
availableProviders.push('codex');
|
||||
}
|
||||
if (opencode.installed && opencode.auth.authenticated) {
|
||||
availableProviders.push('opencode');
|
||||
}
|
||||
|
||||
const response: UnifiedStatusResponse = {
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
clis: {
|
||||
claude,
|
||||
cursor,
|
||||
codex,
|
||||
opencode,
|
||||
},
|
||||
availableProviders,
|
||||
hasAnyAuthenticated: availableProviders.length > 0,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logError(error, 'Get unified CLI status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
163
apps/server/src/routes/setup/routes/verify-coderabbit-auth.ts
Normal file
163
apps/server/src/routes/setup/routes/verify-coderabbit-auth.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* POST /verify-coderabbit-auth endpoint - Verify CodeRabbit authentication
|
||||
* Validates API key format and optionally tests the connection
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { AuthRateLimiter, validateApiKey } from '../../../lib/auth-utils.js';
|
||||
|
||||
const logger = createLogger('Setup');
|
||||
const rateLimiter = new AuthRateLimiter();
|
||||
|
||||
/**
|
||||
* Test CodeRabbit CLI authentication by running a simple command
|
||||
*/
|
||||
async function testCodeRabbitCli(
|
||||
apiKey?: string
|
||||
): Promise<{ authenticated: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
// Set up environment with API key if provided
|
||||
const env = { ...process.env };
|
||||
if (apiKey) {
|
||||
env.CODERABBIT_API_KEY = apiKey;
|
||||
}
|
||||
|
||||
// Try to run coderabbit auth status to verify auth
|
||||
const child = spawn('coderabbit', ['auth', 'status'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
// Check output for authentication status
|
||||
const output = stdout.toLowerCase() + stderr.toLowerCase();
|
||||
if (
|
||||
output.includes('authenticated') ||
|
||||
output.includes('logged in') ||
|
||||
output.includes('valid')
|
||||
) {
|
||||
resolve({ authenticated: true });
|
||||
} else if (output.includes('not authenticated') || output.includes('not logged in')) {
|
||||
resolve({ authenticated: false, error: 'CodeRabbit CLI is not authenticated.' });
|
||||
} else {
|
||||
// Command succeeded, assume authenticated
|
||||
resolve({ authenticated: true });
|
||||
}
|
||||
} else {
|
||||
// Command failed
|
||||
const errorMsg = stderr || stdout || 'CodeRabbit CLI authentication check failed.';
|
||||
resolve({ authenticated: false, error: errorMsg.trim() });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
// CodeRabbit CLI not installed or other error
|
||||
resolve({ authenticated: false, error: `CodeRabbit CLI error: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CodeRabbit API key format
|
||||
* CodeRabbit API keys typically start with 'cr-'
|
||||
*/
|
||||
function validateCodeRabbitKey(apiKey: string): { isValid: boolean; error?: string } {
|
||||
if (!apiKey || apiKey.trim().length === 0) {
|
||||
return { isValid: false, error: 'API key cannot be empty.' };
|
||||
}
|
||||
|
||||
// CodeRabbit API keys typically start with 'cr-'
|
||||
if (!apiKey.startsWith('cr-')) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid CodeRabbit API key format. Keys should start with "cr-".',
|
||||
};
|
||||
}
|
||||
|
||||
if (apiKey.length < 10) {
|
||||
return { isValid: false, error: 'API key is too short.' };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function createVerifyCodeRabbitAuthHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { authMethod, apiKey } = req.body as {
|
||||
authMethod?: 'cli' | 'api_key';
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
// Rate limiting to prevent abuse
|
||||
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
if (!rateLimiter.canAttempt(clientIp)) {
|
||||
const resetTime = rateLimiter.getResetTime(clientIp);
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: 'Too many authentication attempts. Please try again later.',
|
||||
resetTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[Setup] Verifying CodeRabbit authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
|
||||
);
|
||||
|
||||
// For API key verification
|
||||
if (authMethod === 'api_key' && apiKey) {
|
||||
// Validate key format
|
||||
const validation = validateCodeRabbitKey(apiKey);
|
||||
if (!validation.isValid) {
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: validation.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Test the CLI with the provided API key
|
||||
const result = await testCodeRabbitCli(apiKey);
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: result.authenticated,
|
||||
error: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For CLI auth or auto detection
|
||||
const result = await testCodeRabbitCli();
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: result.authenticated,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[Setup] Verify CodeRabbit auth endpoint error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: error instanceof Error ? error.message : 'Verification failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
1244
apps/server/src/services/code-review-service.ts
Normal file
1244
apps/server/src/services/code-review-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -420,6 +420,7 @@ export class SettingsService {
|
||||
anthropic: { configured: boolean; masked: string };
|
||||
google: { configured: boolean; masked: string };
|
||||
openai: { configured: boolean; masked: string };
|
||||
coderabbit: { configured: boolean; masked: string };
|
||||
}> {
|
||||
const credentials = await this.getCredentials();
|
||||
|
||||
@@ -441,6 +442,10 @@ export class SettingsService {
|
||||
configured: !!credentials.apiKeys.openai,
|
||||
masked: maskKey(credentials.apiKeys.openai),
|
||||
},
|
||||
coderabbit: {
|
||||
configured: !!credentials.apiKeys.coderabbit,
|
||||
masked: maskKey(credentials.apiKeys.coderabbit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -658,12 +663,14 @@ export class SettingsService {
|
||||
anthropic?: string;
|
||||
google?: string;
|
||||
openai?: string;
|
||||
coderabbit?: string;
|
||||
};
|
||||
await this.updateCredentials({
|
||||
apiKeys: {
|
||||
anthropic: apiKeys.anthropic || '',
|
||||
google: apiKeys.google || '',
|
||||
openai: apiKeys.openai || '',
|
||||
coderabbit: apiKeys.coderabbit || '',
|
||||
},
|
||||
});
|
||||
migratedCredentials = true;
|
||||
|
||||
196
apps/server/tests/unit/routes/code-review/providers.test.ts
Normal file
196
apps/server/tests/unit/routes/code-review/providers.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Unit tests for code-review providers route handler
|
||||
*
|
||||
* Tests:
|
||||
* - Returns provider status list
|
||||
* - Returns recommended provider
|
||||
* - Force refresh functionality
|
||||
* - Error handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createProvidersHandler } from '@/routes/code-review/routes/providers.js';
|
||||
import type { CodeReviewService } from '@/services/code-review-service.js';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
|
||||
return {
|
||||
...actual,
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('code-review/providers route', () => {
|
||||
let mockCodeReviewService: CodeReviewService;
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
const mockProviderStatuses = [
|
||||
{
|
||||
provider: 'claude' as const,
|
||||
available: true,
|
||||
authenticated: true,
|
||||
version: '1.0.0',
|
||||
issues: [],
|
||||
},
|
||||
{
|
||||
provider: 'codex' as const,
|
||||
available: true,
|
||||
authenticated: false,
|
||||
version: '0.5.0',
|
||||
issues: ['Not authenticated'],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockCodeReviewService = {
|
||||
getProviderStatus: vi.fn().mockResolvedValue(mockProviderStatuses),
|
||||
getBestProvider: vi.fn().mockResolvedValue('claude'),
|
||||
executeReview: vi.fn(),
|
||||
refreshProviderStatus: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
req.query = {};
|
||||
});
|
||||
|
||||
describe('successful responses', () => {
|
||||
it('should return provider status and recommended provider', async () => {
|
||||
const handler = createProvidersHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
providers: mockProviderStatuses,
|
||||
recommended: 'claude',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use cached status by default', async () => {
|
||||
const handler = createProvidersHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockCodeReviewService.getProviderStatus).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should force refresh when refresh=true query param is set', async () => {
|
||||
req.query = { refresh: 'true' };
|
||||
|
||||
const handler = createProvidersHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockCodeReviewService.getProviderStatus).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should handle no recommended provider', async () => {
|
||||
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue(null);
|
||||
|
||||
const handler = createProvidersHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
providers: mockProviderStatuses,
|
||||
recommended: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty provider list', async () => {
|
||||
mockCodeReviewService.getProviderStatus = vi.fn().mockResolvedValue([]);
|
||||
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue(null);
|
||||
|
||||
const handler = createProvidersHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
providers: [],
|
||||
recommended: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle getProviderStatus errors', async () => {
|
||||
mockCodeReviewService.getProviderStatus = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Failed to detect CLIs'));
|
||||
|
||||
const handler = createProvidersHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Failed to detect CLIs',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle getBestProvider errors gracefully', async () => {
|
||||
mockCodeReviewService.getBestProvider = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Detection failed'));
|
||||
|
||||
const handler = createProvidersHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Detection failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider priority', () => {
|
||||
it('should recommend claude when available and authenticated', async () => {
|
||||
const handler = createProvidersHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recommended: 'claude',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should recommend codex when claude is not available', async () => {
|
||||
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue('codex');
|
||||
|
||||
const handler = createProvidersHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recommended: 'codex',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should recommend cursor as fallback', async () => {
|
||||
mockCodeReviewService.getBestProvider = vi.fn().mockResolvedValue('cursor');
|
||||
|
||||
const handler = createProvidersHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recommended: 'cursor',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
109
apps/server/tests/unit/routes/code-review/status.test.ts
Normal file
109
apps/server/tests/unit/routes/code-review/status.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Unit tests for code-review status route handler
|
||||
*
|
||||
* Tests:
|
||||
* - Returns correct running status
|
||||
* - Returns correct project path
|
||||
* - Handles errors gracefully
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createStatusHandler } from '@/routes/code-review/routes/status.js';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
// Mock the common module to control running state
|
||||
vi.mock('@/routes/code-review/common.js', () => {
|
||||
return {
|
||||
isRunning: vi.fn(),
|
||||
getReviewStatus: vi.fn(),
|
||||
getCurrentProjectPath: vi.fn(),
|
||||
setRunningState: vi.fn(),
|
||||
getAbortController: vi.fn(),
|
||||
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
|
||||
logError: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
|
||||
return {
|
||||
...actual,
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('code-review/status route', () => {
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
describe('when no review is running', () => {
|
||||
it('should return isRunning: false with null projectPath', async () => {
|
||||
const { getReviewStatus } = await import('@/routes/code-review/common.js');
|
||||
vi.mocked(getReviewStatus).mockReturnValue({
|
||||
isRunning: false,
|
||||
projectPath: null,
|
||||
});
|
||||
|
||||
const handler = createStatusHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
isRunning: false,
|
||||
projectPath: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a review is running', () => {
|
||||
it('should return isRunning: true with the current project path', async () => {
|
||||
const { getReviewStatus } = await import('@/routes/code-review/common.js');
|
||||
vi.mocked(getReviewStatus).mockReturnValue({
|
||||
isRunning: true,
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
|
||||
const handler = createStatusHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
isRunning: true,
|
||||
projectPath: '/test/project',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle errors gracefully', async () => {
|
||||
const { getReviewStatus } = await import('@/routes/code-review/common.js');
|
||||
vi.mocked(getReviewStatus).mockImplementation(() => {
|
||||
throw new Error('Unexpected error');
|
||||
});
|
||||
|
||||
const handler = createStatusHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Unexpected error',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
129
apps/server/tests/unit/routes/code-review/stop.test.ts
Normal file
129
apps/server/tests/unit/routes/code-review/stop.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Unit tests for code-review stop route handler
|
||||
*
|
||||
* Tests:
|
||||
* - Stopping when no review is running
|
||||
* - Stopping a running review
|
||||
* - Abort controller behavior
|
||||
* - Error handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createStopHandler } from '@/routes/code-review/routes/stop.js';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
// Mock the common module
|
||||
vi.mock('@/routes/code-review/common.js', () => {
|
||||
return {
|
||||
isRunning: vi.fn(),
|
||||
getAbortController: vi.fn(),
|
||||
setRunningState: vi.fn(),
|
||||
getReviewStatus: vi.fn(),
|
||||
getCurrentProjectPath: vi.fn(),
|
||||
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
|
||||
logError: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
|
||||
return {
|
||||
...actual,
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('code-review/stop route', () => {
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
describe('when no review is running', () => {
|
||||
it('should return success with message that nothing is running', async () => {
|
||||
const { isRunning } = await import('@/routes/code-review/common.js');
|
||||
vi.mocked(isRunning).mockReturnValue(false);
|
||||
|
||||
const handler = createStopHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'No code review is currently running',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a review is running', () => {
|
||||
it('should abort the review and reset running state', async () => {
|
||||
const { isRunning, getAbortController, setRunningState } =
|
||||
await import('@/routes/code-review/common.js');
|
||||
|
||||
const mockAbortController = {
|
||||
abort: vi.fn(),
|
||||
signal: { aborted: false },
|
||||
};
|
||||
|
||||
vi.mocked(isRunning).mockReturnValue(true);
|
||||
vi.mocked(getAbortController).mockReturnValue(mockAbortController as any);
|
||||
|
||||
const handler = createStopHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(mockAbortController.abort).toHaveBeenCalled();
|
||||
expect(setRunningState).toHaveBeenCalledWith(false, null, null);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Code review stopped',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle case when abort controller is null', async () => {
|
||||
const { isRunning, getAbortController, setRunningState } =
|
||||
await import('@/routes/code-review/common.js');
|
||||
|
||||
vi.mocked(isRunning).mockReturnValue(true);
|
||||
vi.mocked(getAbortController).mockReturnValue(null);
|
||||
|
||||
const handler = createStopHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(setRunningState).toHaveBeenCalledWith(false, null, null);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Code review stopped',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle errors gracefully', async () => {
|
||||
const { isRunning } = await import('@/routes/code-review/common.js');
|
||||
vi.mocked(isRunning).mockImplementation(() => {
|
||||
throw new Error('Unexpected error');
|
||||
});
|
||||
|
||||
const handler = createStopHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Unexpected error',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
384
apps/server/tests/unit/routes/code-review/trigger.test.ts
Normal file
384
apps/server/tests/unit/routes/code-review/trigger.test.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Unit tests for code-review trigger route handler
|
||||
*
|
||||
* Tests:
|
||||
* - Parameter validation
|
||||
* - Request body validation (security)
|
||||
* - Concurrent review prevention
|
||||
* - Review execution
|
||||
* - Error handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createTriggerHandler } from '@/routes/code-review/routes/trigger.js';
|
||||
import type { CodeReviewService } from '@/services/code-review-service.js';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
// Mock the common module to control running state
|
||||
vi.mock('@/routes/code-review/common.js', () => {
|
||||
let running = false;
|
||||
return {
|
||||
isRunning: vi.fn(() => running),
|
||||
setRunningState: vi.fn((state: boolean) => {
|
||||
running = state;
|
||||
}),
|
||||
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
|
||||
logError: vi.fn(),
|
||||
getAbortController: vi.fn(() => null),
|
||||
getCurrentProjectPath: vi.fn(() => null),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock logger
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
|
||||
return {
|
||||
...actual,
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('code-review/trigger route', () => {
|
||||
let mockCodeReviewService: CodeReviewService;
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset running state
|
||||
const { setRunningState, isRunning } = await import('@/routes/code-review/common.js');
|
||||
vi.mocked(setRunningState)(false);
|
||||
vi.mocked(isRunning).mockReturnValue(false);
|
||||
|
||||
mockCodeReviewService = {
|
||||
executeReview: vi.fn().mockResolvedValue({
|
||||
id: 'review-123',
|
||||
verdict: 'approved',
|
||||
summary: 'No issues found',
|
||||
comments: [],
|
||||
stats: {
|
||||
totalComments: 0,
|
||||
bySeverity: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
|
||||
byCategory: {},
|
||||
autoFixedCount: 0,
|
||||
},
|
||||
filesReviewed: ['src/index.ts'],
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
reviewedAt: new Date().toISOString(),
|
||||
durationMs: 1000,
|
||||
}),
|
||||
getProviderStatus: vi.fn(),
|
||||
getBestProvider: vi.fn(),
|
||||
refreshProviderStatus: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
describe('parameter validation', () => {
|
||||
it('should return 400 if projectPath is missing', async () => {
|
||||
req.body = {};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'projectPath is required',
|
||||
});
|
||||
expect(mockCodeReviewService.executeReview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 if files is not an array', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
files: 'not-an-array',
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'files must be an array',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if too many files', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
files: Array.from({ length: 150 }, (_, i) => `file${i}.ts`),
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Maximum 100 files allowed per request',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if file path is too long', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
files: ['a'.repeat(600)],
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'File path too long',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if baseRef is not a string', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
baseRef: 123,
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'baseRef must be a string',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if baseRef is too long', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
baseRef: 'a'.repeat(300),
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'baseRef is too long',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if categories is not an array', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
categories: 'security',
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'categories must be an array',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if category is invalid', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
categories: ['security', 'invalid_category'],
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Invalid category: invalid_category',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if autoFix is not a boolean', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
autoFix: 'true',
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'autoFix must be a boolean',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if thinkingLevel is invalid', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
thinkingLevel: 'invalid',
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Invalid thinkingLevel: invalid',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent review prevention', () => {
|
||||
it('should return 409 if a review is already in progress', async () => {
|
||||
const { isRunning } = await import('@/routes/code-review/common.js');
|
||||
vi.mocked(isRunning).mockReturnValue(true);
|
||||
|
||||
req.body = { projectPath: '/test/project' };
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(409);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'A code review is already in progress',
|
||||
});
|
||||
expect(mockCodeReviewService.executeReview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful review execution', () => {
|
||||
it('should trigger review and return success immediately', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Code review started',
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass all options to executeReview', async () => {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
files: ['src/index.ts', 'src/utils.ts'],
|
||||
baseRef: 'main',
|
||||
categories: ['security', 'performance'],
|
||||
autoFix: true,
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
thinkingLevel: 'high',
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
// Wait for async execution
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(mockCodeReviewService.executeReview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectPath: '/test/project',
|
||||
files: ['src/index.ts', 'src/utils.ts'],
|
||||
baseRef: 'main',
|
||||
categories: ['security', 'performance'],
|
||||
autoFix: true,
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
thinkingLevel: 'high',
|
||||
abortController: expect.any(AbortController),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept valid categories', async () => {
|
||||
const validCategories = [
|
||||
'tech_stack',
|
||||
'security',
|
||||
'code_quality',
|
||||
'implementation',
|
||||
'architecture',
|
||||
'performance',
|
||||
'testing',
|
||||
'documentation',
|
||||
];
|
||||
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
categories: validCategories,
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Code review started',
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept valid thinking levels', async () => {
|
||||
for (const level of ['low', 'medium', 'high']) {
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
thinkingLevel: level,
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Code review started',
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle service errors gracefully', async () => {
|
||||
mockCodeReviewService.executeReview = vi.fn().mockRejectedValue(new Error('Service error'));
|
||||
|
||||
req.body = {
|
||||
projectPath: '/test/project',
|
||||
};
|
||||
|
||||
const handler = createTriggerHandler(mockCodeReviewService);
|
||||
await handler(req, res);
|
||||
|
||||
// Response is sent immediately (async execution)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Code review started',
|
||||
});
|
||||
|
||||
// Wait for async error handling
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Running state should be reset
|
||||
const { setRunningState } = await import('@/routes/code-review/common.js');
|
||||
expect(setRunningState).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
1048
apps/server/tests/unit/services/code-review-service.test.ts
Normal file
1048
apps/server/tests/unit/services/code-review-service.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user