feat: add CodeRabbit integration for AI-powered code reviews

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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