feat: Enhance the look of the PR Detail area (#427)
* fix: Allow windows to run CC PR Reviewer Signed-off-by: Alex Madera <e.a_madera@hotmail.com> * solve auto claude comments * fix linting * feat: enhance github PR page to include filters * wip: enhance the look of pr details * nicer view of status/flow * use better card * refactor into their own components (SOLID) * feat(i18n): add comprehensive i18n translations to PR review components Replace all hardcoded English strings with i18n translation keys: - severity-config.ts: Use labelKey/descriptionKey instead of hardcoded labels - ReviewStatusTree.tsx: Translate all status labels, step labels, button texts - PRHeader.tsx: Translate "files" label and title attribute - PRDetail.tsx: Translate all prStatus description messages - ReviewFindings.tsx: Translate quick select buttons and empty state - FindingsSummary.tsx: Translate severity labels and selection count - FindingItem.tsx: Translate "Posted" badge and "Suggested fix" label - SeverityGroupHeader.tsx: Use translated labelKey and descriptionKey Added 35+ new translation keys to en/common.json and fr/common.json: - Severity labels and descriptions - Status descriptions with pluralization support - Action labels and button texts - Empty state messages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix typecheck * fix: address 15 PR review findings in github-prs components HIGH priority fixes: - Fix postedCount double-counting bug by using merged Set approach - Fix handleAutoApprove to check onPostReview return value before proceeding - Fix flowState priority order in PRList to prioritize more advanced states - Remove dead code in usePRFiltering.ts (unreachable hasPostedFindings check) MEDIUM priority fixes: - Add explicit handling for needs_attention and followup_issues_remain statuses - Fix race condition in checkForNewCommits with guard and AbortController - Sync postedFindingIds local state with reviewResult.postedFindingIds - Add date validation and i18n locale support to formatDate functions - Handle edge case in ReviewStatusTree for follow-up reviews with null previousResult - Add console.warn for startFollowupReview called without previous result LOW priority fixes: - Remove unused activePRReviews prop from PRList component - Use i18n.language for date formatting in PRHeader - Use i18next built-in pluralization for new commits display - Handle zero findings case in isCleanReview for auto-approve button - Add category translations with i18n keys in FindingItem Also adds category translation keys for en/fr locales. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: pass newCommitsCheck from store to PRDetail for follow-up button The follow-up review button was not showing because PRDetail used local state for newCommitsCheck which was not synced with the store data. Changes: - Add initialNewCommitsCheck prop to PRDetail component - Pass storedNewCommitsCheck from store to PRDetail in GitHubPRs.tsx - Add effect to sync local state with store value when it changes - Update getReviewStateForPR type to include newCommitsCheck This ensures the "Run Follow-up" button appears when the store has detected new commits, matching what the PR list shows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract formatDate utility, add state translations, fix useCallback dependency - Extract duplicated formatDate function to shared utils/formatDate.ts - Add pr.state translation keys (open, closed, merged) to en/fr locales - Fix checkForNewCommits useCallback by using ref to avoid unnecessary recreations - Add comment explaining GitHub approval comments are intentionally English-only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: PRList status not showing Ready to Merge for clean follow-up reviews The hasPosted check now accounts for: - postedFindingIds array length - Follow-up reviews with 0 findings (all issues resolved) This fixes the mismatch where PRDetail showed "Ready to Merge" but PRList showed "Pending Post" for follow-up reviews that resolved all issues. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: remove unused isCheckingNewCommits state variable The ref isCheckingNewCommitsRef handles the guard logic, making the state variable redundant. Removed the state and its setter calls to follow React best practices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Signed-off-by: Alex Madera <e.a_madera@hotmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
@@ -64,7 +64,6 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
reviewResult,
|
||||
reviewProgress,
|
||||
isReviewing,
|
||||
activePRReviews,
|
||||
selectPR,
|
||||
runReview,
|
||||
runFollowupReview,
|
||||
@@ -82,6 +81,13 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
|
||||
const selectedPR = prs.find(pr => pr.number === selectedPRNumber);
|
||||
|
||||
// Get previousResult and newCommitsCheck for follow-up review continuity
|
||||
const selectedPRReviewState = selectedPRNumber
|
||||
? getReviewStateForPR(selectedPRNumber)
|
||||
: null;
|
||||
const previousReviewResult = selectedPRReviewState?.previousResult ?? null;
|
||||
const storedNewCommitsCheck = selectedPRReviewState?.newCommitsCheck ?? null;
|
||||
|
||||
// PR filtering
|
||||
const {
|
||||
filteredPRs,
|
||||
@@ -215,8 +221,10 @@ export function GitHubPRs({ onOpenSettings }: GitHubPRsProps) {
|
||||
<PRDetail
|
||||
pr={selectedPR}
|
||||
reviewResult={reviewResult}
|
||||
previousReviewResult={previousReviewResult}
|
||||
reviewProgress={reviewProgress}
|
||||
isReviewing={isReviewing}
|
||||
initialNewCommitsCheck={storedNewCommitsCheck}
|
||||
onRunReview={handleRunReview}
|
||||
onRunFollowupReview={handleRunFollowupReview}
|
||||
onCheckNewCommits={handleCheckNewCommits}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../ui/collapsible';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
export interface CollapsibleCardProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
headerAction?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable Collapsible Card Component
|
||||
* Consistent styling for collapsible sections throughout the PR review UI
|
||||
*/
|
||||
export function CollapsibleCard({
|
||||
title,
|
||||
icon,
|
||||
badge,
|
||||
headerAction,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
className,
|
||||
}: CollapsibleCardProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
const setIsOpen = onOpenChange || setInternalOpen;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className={cn("border rounded-lg bg-card shadow-sm overflow-hidden", className)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="p-4 flex items-center justify-between gap-3 bg-muted/30 cursor-pointer hover:bg-muted/40 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{icon && <div className="shrink-0">{icon}</div>}
|
||||
<span className="font-medium truncate">{title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{headerAction}
|
||||
{badge}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import { cn } from '../../../lib/utils';
|
||||
@@ -16,9 +17,30 @@ interface FindingItemProps {
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
// Helper to translate category names
|
||||
function getCategoryTranslationKey(category: string): string {
|
||||
// Map category values to translation keys
|
||||
const categoryMap: Record<string, string> = {
|
||||
'security': 'prReview.category.security',
|
||||
'logic': 'prReview.category.logic',
|
||||
'quality': 'prReview.category.quality',
|
||||
'performance': 'prReview.category.performance',
|
||||
'style': 'prReview.category.style',
|
||||
'documentation': 'prReview.category.documentation',
|
||||
'testing': 'prReview.category.testing',
|
||||
'other': 'prReview.category.other',
|
||||
};
|
||||
return categoryMap[category.toLowerCase()] || category;
|
||||
}
|
||||
|
||||
export function FindingItem({ finding, selected, posted = false, onToggle }: FindingItemProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const CategoryIcon = getCategoryIcon(finding.category);
|
||||
|
||||
// Get translated category name (falls back to original if translation not found)
|
||||
const categoryKey = getCategoryTranslationKey(finding.category);
|
||||
const categoryLabel = t(categoryKey, { defaultValue: finding.category });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -43,11 +65,11 @@ export function FindingItem({ finding, selected, posted = false, onToggle }: Fin
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
<CategoryIcon className="h-3 w-3 mr-1" />
|
||||
{finding.category}
|
||||
{categoryLabel}
|
||||
</Badge>
|
||||
{posted && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 text-success border-success/50">
|
||||
Posted
|
||||
{t('prReview.posted')}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="font-medium text-sm break-words">
|
||||
@@ -69,7 +91,7 @@ export function FindingItem({ finding, selected, posted = false, onToggle }: Fin
|
||||
{/* Suggested Fix */}
|
||||
{finding.suggestedFix && (
|
||||
<div className="ml-7 text-xs">
|
||||
<span className="text-muted-foreground font-medium">Suggested fix:</span>
|
||||
<span className="text-muted-foreground font-medium">{t('prReview.suggestedFix')}</span>
|
||||
<pre className="mt-1 p-2 bg-muted rounded text-xs overflow-x-auto max-w-full whitespace-pre-wrap break-words">
|
||||
{finding.suggestedFix}
|
||||
</pre>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* FindingsSummary - Visual summary of finding counts by severity
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import type { PRReviewFinding } from '../hooks/useGitHubPRs';
|
||||
|
||||
@@ -11,6 +12,8 @@ interface FindingsSummaryProps {
|
||||
}
|
||||
|
||||
export function FindingsSummary({ findings, selectedCount }: FindingsSummaryProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
// Count findings by severity
|
||||
const counts = {
|
||||
critical: findings.filter(f => f.severity === 'critical').length,
|
||||
@@ -25,27 +28,27 @@ export function FindingsSummary({ findings, selectedCount }: FindingsSummaryProp
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{counts.critical > 0 && (
|
||||
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/30">
|
||||
{counts.critical} Critical
|
||||
{counts.critical} {t('prReview.severity.critical')}
|
||||
</Badge>
|
||||
)}
|
||||
{counts.high > 0 && (
|
||||
<Badge variant="outline" className="bg-orange-500/10 text-orange-500 border-orange-500/30">
|
||||
{counts.high} High
|
||||
{counts.high} {t('prReview.severity.high')}
|
||||
</Badge>
|
||||
)}
|
||||
{counts.medium > 0 && (
|
||||
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/30">
|
||||
{counts.medium} Medium
|
||||
{counts.medium} {t('prReview.severity.medium')}
|
||||
</Badge>
|
||||
)}
|
||||
{counts.low > 0 && (
|
||||
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/30">
|
||||
{counts.low} Low
|
||||
{counts.low} {t('prReview.severity.low')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedCount}/{counts.total} selected
|
||||
{t('prReview.selectedOfTotal', { selected: selectedCount, total: counts.total })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ExternalLink,
|
||||
User,
|
||||
Clock,
|
||||
GitBranch,
|
||||
FileDiff,
|
||||
Sparkles,
|
||||
Bot,
|
||||
Send,
|
||||
XCircle,
|
||||
Loader2,
|
||||
@@ -14,31 +9,33 @@ import {
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
MessageSquare,
|
||||
AlertTriangle,
|
||||
CheckCheck,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Circle,
|
||||
CircleDot,
|
||||
Play
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card';
|
||||
import { Card, CardContent } from '../../ui/card';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import { Progress } from '../../ui/progress';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
// Local components
|
||||
import { CollapsibleCard } from './CollapsibleCard';
|
||||
import { ReviewStatusTree } from './ReviewStatusTree';
|
||||
import { PRHeader } from './PRHeader';
|
||||
import { ReviewFindings } from './ReviewFindings';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '../../ui/collapsible';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
import type { PRData, PRReviewResult, PRReviewProgress } from '../hooks/useGitHubPRs';
|
||||
import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api';
|
||||
|
||||
interface PRDetailProps {
|
||||
pr: PRData;
|
||||
reviewResult: PRReviewResult | null;
|
||||
previousReviewResult: PRReviewResult | null;
|
||||
reviewProgress: PRReviewProgress | null;
|
||||
isReviewing: boolean;
|
||||
initialNewCommitsCheck?: NewCommitsCheck | null;
|
||||
onRunReview: () => void;
|
||||
onRunFollowupReview: () => void;
|
||||
onCheckNewCommits: () => Promise<NewCommitsCheck>;
|
||||
@@ -49,16 +46,6 @@ interface PRDetailProps {
|
||||
onAssignPR: (username: string) => void;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusColor(status: PRReviewResult['overallStatus']): string {
|
||||
switch (status) {
|
||||
case 'approve':
|
||||
@@ -70,261 +57,13 @@ function getStatusColor(status: PRReviewResult['overallStatus']): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Compact Tree View for Review Process
|
||||
function ReviewStatusTree({
|
||||
status,
|
||||
isReviewing,
|
||||
reviewResult,
|
||||
postedCount,
|
||||
onRunReview,
|
||||
onRunFollowupReview,
|
||||
onCancelReview,
|
||||
newCommitsCheck,
|
||||
lastPostedAt
|
||||
}: {
|
||||
status: 'not_reviewed' | 'reviewed_pending_post' | 'waiting_for_changes' | 'ready_to_merge' | 'needs_attention' | 'ready_for_followup' | 'followup_issues_remain';
|
||||
isReviewing: boolean;
|
||||
reviewResult: PRReviewResult | null;
|
||||
postedCount: number;
|
||||
onRunReview: () => void;
|
||||
onRunFollowupReview: () => void;
|
||||
onCancelReview: () => void;
|
||||
newCommitsCheck: NewCommitsCheck | null;
|
||||
lastPostedAt?: number | null;
|
||||
}) {
|
||||
const { t } = useTranslation('common');
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// If not reviewed, show simple status
|
||||
if (status === 'not_reviewed' && !isReviewing) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-3 p-4 border rounded-lg bg-card shadow-sm">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded-full bg-muted-foreground/30" />
|
||||
<span className="font-medium text-muted-foreground truncate">{t('prReview.notReviewed')}</span>
|
||||
</div>
|
||||
<Button onClick={onRunReview} size="sm" className="gap-2 shrink-0 ml-auto sm:ml-0">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{t('prReview.runAIReview')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine steps for the tree
|
||||
const steps: { id: string; label: string; status: string; date?: string | null; action?: React.ReactNode }[] = [];
|
||||
|
||||
// Step 1: Start
|
||||
steps.push({
|
||||
id: 'start',
|
||||
label: t('prReview.reviewStarted'),
|
||||
status: 'completed',
|
||||
date: reviewResult?.reviewedAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
// Step 2: AI Analysis
|
||||
if (isReviewing) {
|
||||
steps.push({
|
||||
id: 'analysis',
|
||||
label: t('prReview.analysisInProgress'),
|
||||
status: 'current',
|
||||
date: null
|
||||
});
|
||||
} else if (reviewResult) {
|
||||
steps.push({
|
||||
id: 'analysis',
|
||||
label: t('prReview.analysisComplete', { count: reviewResult.findings.length }),
|
||||
status: 'completed',
|
||||
date: reviewResult.reviewedAt
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Posting
|
||||
if (postedCount > 0 || reviewResult?.hasPostedFindings) {
|
||||
steps.push({
|
||||
id: 'posted',
|
||||
label: t('prReview.findingsPostedToGitHub'),
|
||||
status: 'completed',
|
||||
date: reviewResult?.postedAt || (lastPostedAt ? new Date(lastPostedAt).toISOString() : null)
|
||||
});
|
||||
} else if (reviewResult && reviewResult.findings.length > 0) {
|
||||
steps.push({
|
||||
id: 'posted',
|
||||
label: t('prReview.pendingPost'),
|
||||
status: 'pending',
|
||||
date: null
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Follow-up
|
||||
if (newCommitsCheck?.hasNewCommits) {
|
||||
steps.push({
|
||||
id: 'new_commits',
|
||||
label: t('prReview.newCommits', { count: newCommitsCheck.newCommitCount }),
|
||||
status: 'alert',
|
||||
date: null
|
||||
});
|
||||
steps.push({
|
||||
id: 'followup',
|
||||
label: t('prReview.readyForFollowup'),
|
||||
status: 'pending',
|
||||
action: (
|
||||
<Button size="sm" variant="outline" onClick={onRunFollowupReview} className="ml-2 h-6 text-xs px-2">
|
||||
{t('prReview.runFollowup')}
|
||||
</Button>
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className="border rounded-lg bg-card shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Header / Status Bar */}
|
||||
<div className="p-4 flex flex-wrap items-center justify-between gap-y-2 bg-muted/30">
|
||||
<div className="flex items-center gap-3 min-w-0 pr-2">
|
||||
<div className={cn("h-2.5 w-2.5 shrink-0 rounded-full",
|
||||
isReviewing ? "bg-blue-500 animate-pulse" :
|
||||
status === 'ready_to_merge' ? "bg-success" :
|
||||
status === 'waiting_for_changes' ? "bg-warning" :
|
||||
status === 'reviewed_pending_post' ? "bg-primary" :
|
||||
status === 'ready_for_followup' ? "bg-info" :
|
||||
"bg-muted-foreground"
|
||||
)} />
|
||||
<span className="font-medium truncate">
|
||||
{isReviewing ? t('prReview.aiReviewInProgress') :
|
||||
status === 'ready_to_merge' ? t('prReview.readyToMerge') :
|
||||
status === 'waiting_for_changes' ? t('prReview.waitingForChanges') :
|
||||
status === 'reviewed_pending_post' ? t('prReview.reviewComplete') :
|
||||
status === 'ready_for_followup' ? t('prReview.readyForFollowup') :
|
||||
t('prReview.reviewStatus')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-auto sm:ml-0">
|
||||
{isReviewing && (
|
||||
<Button variant="ghost" size="sm" onClick={onCancelReview} className="h-7 text-destructive hover:text-destructive">
|
||||
{t('buttons.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collapsible Tree */}
|
||||
<CollapsibleContent>
|
||||
<div className="p-4 pt-0">
|
||||
<div className="relative pl-2 ml-2 border-l border-border/50 space-y-4 pt-4">
|
||||
{steps.map((step) => (
|
||||
<div key={step.id} className="relative flex items-start gap-3 pl-4">
|
||||
{/* Node Dot */}
|
||||
<div className={cn("absolute -left-[13px] top-1 bg-background rounded-full p-0.5 border",
|
||||
step.status === 'completed' ? "border-success text-success" :
|
||||
step.status === 'current' ? "border-primary text-primary animate-pulse" :
|
||||
step.status === 'alert' ? "border-warning text-warning" :
|
||||
"border-muted-foreground text-muted-foreground"
|
||||
)}>
|
||||
{step.status === 'completed' ? <CheckCircle className="h-3 w-3" /> :
|
||||
step.status === 'current' ? <CircleDot className="h-3 w-3" /> :
|
||||
<Circle className="h-3 w-3" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className={cn("text-sm font-medium truncate max-w-full",
|
||||
step.status === 'completed' ? "text-foreground" :
|
||||
step.status === 'current' ? "text-primary" :
|
||||
"text-muted-foreground"
|
||||
)}>
|
||||
{step.label}
|
||||
</span>
|
||||
{step.action}
|
||||
</div>
|
||||
{step.date && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDate(step.date)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// Modern Header Component
|
||||
function PRHeader({ pr }: { pr: PRData }) {
|
||||
const { t } = useTranslation('common');
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={pr.state.toLowerCase() === 'open' ? 'success' : 'secondary'} className={cn(
|
||||
"capitalize px-2.5 py-0.5",
|
||||
pr.state.toLowerCase() === 'open' ? "bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25 border-emerald-500/20" : ""
|
||||
)}>
|
||||
{pr.state}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm font-mono">#{pr.number}</span>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8 text-muted-foreground hover:text-foreground">
|
||||
<a href={pr.htmlUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-bold mb-4 leading-tight">{pr.title}</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm text-muted-foreground border-b border-border/40 pb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted rounded-full p-1">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{pr.author.login}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 opacity-70" />
|
||||
<span>{formatDate(pr.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 font-mono text-xs border border-border/50">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<span className="text-foreground">{pr.headRefName}</span>
|
||||
<span className="text-muted-foreground/50 mx-1">→</span>
|
||||
<span className="text-foreground">{pr.baseRefName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
<div className="flex items-center gap-1.5" title={t('prReview.filesChanged', { count: pr.changedFiles })}>
|
||||
<FileDiff className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">{pr.changedFiles}</span>
|
||||
<span className="text-xs">{t('prReview.files')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-mono">
|
||||
<span className="text-emerald-500 bg-emerald-500/10 px-1.5 py-0.5 rounded">+{pr.additions}</span>
|
||||
<span className="text-red-500 bg-red-500/10 px-1.5 py-0.5 rounded">-{pr.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRDetail({
|
||||
pr,
|
||||
reviewResult,
|
||||
previousReviewResult,
|
||||
reviewProgress,
|
||||
isReviewing,
|
||||
initialNewCommitsCheck,
|
||||
onRunReview,
|
||||
onRunFollowupReview,
|
||||
onCheckNewCommits,
|
||||
@@ -334,7 +73,7 @@ export function PRDetail({
|
||||
onMergePR,
|
||||
onAssignPR: _onAssignPR,
|
||||
}: PRDetailProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const { t, i18n } = useTranslation('common');
|
||||
// Selection state for findings
|
||||
const [selectedFindingIds, setSelectedFindingIds] = useState<Set<string>>(new Set());
|
||||
const [postedFindingIds, setPostedFindingIds] = useState<Set<string>>(new Set());
|
||||
@@ -342,8 +81,28 @@ export function PRDetail({
|
||||
const [postSuccess, setPostSuccess] = useState<{ count: number; timestamp: number } | null>(null);
|
||||
const [isPosting, setIsPosting] = useState(false);
|
||||
const [isMerging, setIsMerging] = useState(false);
|
||||
const [newCommitsCheck, setNewCommitsCheck] = useState<NewCommitsCheck | null>(null);
|
||||
const [, setIsCheckingNewCommits] = useState(false);
|
||||
// Initialize with store value, then sync and update via local checks
|
||||
const [newCommitsCheck, setNewCommitsCheck] = useState<NewCommitsCheck | null>(initialNewCommitsCheck ?? null);
|
||||
const [analysisExpanded, setAnalysisExpanded] = useState(true);
|
||||
const checkNewCommitsAbortRef = useRef<AbortController | null>(null);
|
||||
// Ref to track checking state without causing callback recreation
|
||||
const isCheckingNewCommitsRef = useRef(false);
|
||||
|
||||
// Sync with store's newCommitsCheck when it changes (e.g., when switching PRs)
|
||||
useEffect(() => {
|
||||
if (initialNewCommitsCheck !== undefined) {
|
||||
setNewCommitsCheck(initialNewCommitsCheck);
|
||||
}
|
||||
}, [initialNewCommitsCheck]);
|
||||
|
||||
// Sync local postedFindingIds with reviewResult.postedFindingIds when it changes
|
||||
useEffect(() => {
|
||||
if (reviewResult?.postedFindingIds) {
|
||||
setPostedFindingIds(new Set(reviewResult.postedFindingIds));
|
||||
} else {
|
||||
setPostedFindingIds(new Set());
|
||||
}
|
||||
}, [reviewResult?.postedFindingIds, pr.number]);
|
||||
|
||||
// Auto-select critical and high findings when review completes (excluding already posted)
|
||||
useEffect(() => {
|
||||
@@ -360,14 +119,30 @@ export function PRDetail({
|
||||
const hasPostedFindings = postedFindingIds.size > 0 || reviewResult?.hasPostedFindings;
|
||||
|
||||
const checkForNewCommits = useCallback(async () => {
|
||||
// Prevent duplicate concurrent calls using ref (avoids callback recreation)
|
||||
if (isCheckingNewCommitsRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending check
|
||||
if (checkNewCommitsAbortRef.current) {
|
||||
checkNewCommitsAbortRef.current.abort();
|
||||
}
|
||||
checkNewCommitsAbortRef.current = new AbortController();
|
||||
|
||||
// Only check for new commits if we have a review AND findings have been posted
|
||||
if (reviewResult?.success && reviewResult.reviewedCommitSha && hasPostedFindings) {
|
||||
setIsCheckingNewCommits(true);
|
||||
isCheckingNewCommitsRef.current = true;
|
||||
try {
|
||||
const result = await onCheckNewCommits();
|
||||
setNewCommitsCheck(result);
|
||||
// Only update state if not aborted
|
||||
if (!checkNewCommitsAbortRef.current?.signal.aborted) {
|
||||
setNewCommitsCheck(result);
|
||||
}
|
||||
} finally {
|
||||
setIsCheckingNewCommits(false);
|
||||
if (!checkNewCommitsAbortRef.current?.signal.aborted) {
|
||||
isCheckingNewCommitsRef.current = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Clear any existing new commits check if we haven't posted yet
|
||||
@@ -377,6 +152,12 @@ export function PRDetail({
|
||||
|
||||
useEffect(() => {
|
||||
checkForNewCommits();
|
||||
return () => {
|
||||
// Cleanup abort controller on unmount
|
||||
if (checkNewCommitsAbortRef.current) {
|
||||
checkNewCommitsAbortRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [checkForNewCommits]);
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
@@ -397,23 +178,47 @@ export function PRDetail({
|
||||
return reviewResult.summary?.includes('READY TO MERGE') || reviewResult.overallStatus === 'approve';
|
||||
}, [reviewResult]);
|
||||
|
||||
// Check if review is "clean" - only LOW severity findings (no MEDIUM, HIGH, or CRITICAL)
|
||||
// Requires at least having a successful review to be considered clean
|
||||
const isCleanReview = useMemo(() => {
|
||||
if (!reviewResult || !reviewResult.success) return false;
|
||||
// Only LOW findings allowed - no medium, high, or critical
|
||||
// A review with zero findings is also considered clean
|
||||
return !reviewResult.findings.some(f =>
|
||||
f.severity === 'critical' || f.severity === 'high' || f.severity === 'medium'
|
||||
);
|
||||
}, [reviewResult]);
|
||||
|
||||
// Check if there are any findings at all (for auto-approve button label)
|
||||
const hasFindings = useMemo(() => {
|
||||
return reviewResult?.findings && reviewResult.findings.length > 0;
|
||||
}, [reviewResult]);
|
||||
|
||||
// Get LOW severity findings for auto-posting
|
||||
const lowSeverityFindings = useMemo(() => {
|
||||
if (!reviewResult?.findings) return [];
|
||||
return reviewResult.findings.filter(f => f.severity === 'low');
|
||||
}, [reviewResult]);
|
||||
|
||||
// Compute the overall PR review status for visual display
|
||||
type PRStatus = 'not_reviewed' | 'reviewed_pending_post' | 'waiting_for_changes' | 'ready_to_merge' | 'needs_attention' | 'ready_for_followup' | 'followup_issues_remain';
|
||||
const prStatus: { status: PRStatus; label: string; description: string; icon: React.ReactNode; color: string } = useMemo(() => {
|
||||
if (!reviewResult || !reviewResult.success) {
|
||||
return {
|
||||
status: 'not_reviewed',
|
||||
label: 'Not Reviewed',
|
||||
description: 'Run an AI review to analyze this PR',
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
label: t('prReview.notReviewed'),
|
||||
description: t('prReview.runAIReviewDesc'),
|
||||
icon: <Bot className="h-5 w-5" />,
|
||||
color: 'bg-muted text-muted-foreground border-muted',
|
||||
};
|
||||
}
|
||||
|
||||
const totalPosted = postedFindingIds.size + (reviewResult.postedFindingIds?.length ?? 0);
|
||||
// Use a merged Set to avoid double-counting (local state may overlap with backend state)
|
||||
const allPostedIds = new Set([...postedFindingIds, ...(reviewResult.postedFindingIds ?? [])]);
|
||||
const totalPosted = allPostedIds.size;
|
||||
const hasPosted = totalPosted > 0 || reviewResult.hasPostedFindings;
|
||||
const hasBlockers = reviewResult.findings.some(f => f.severity === 'critical' || f.severity === 'high');
|
||||
const unpostedFindings = reviewResult.findings.filter(f => !postedFindingIds.has(f.id) && !reviewResult.postedFindingIds?.includes(f.id));
|
||||
const unpostedFindings = reviewResult.findings.filter(f => !allPostedIds.has(f.id));
|
||||
const hasUnpostedBlockers = unpostedFindings.some(f => f.severity === 'critical' || f.severity === 'high');
|
||||
const hasNewCommits = newCommitsCheck?.hasNewCommits ?? false;
|
||||
const newCommitCount = newCommitsCheck?.newCommitCount ?? 0;
|
||||
@@ -433,8 +238,8 @@ export function PRDetail({
|
||||
if (hasNewCommits) {
|
||||
return {
|
||||
status: 'ready_for_followup',
|
||||
label: 'Ready for Follow-up',
|
||||
description: `${newCommitCount} new commit${newCommitCount !== 1 ? 's' : ''} since follow-up. Run another follow-up review.`,
|
||||
label: t('prReview.readyForFollowup'),
|
||||
description: t('prReview.newCommitsSinceFollowup', { count: newCommitCount }),
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
color: 'bg-info/20 text-info border-info/50',
|
||||
};
|
||||
@@ -444,8 +249,8 @@ export function PRDetail({
|
||||
if (unresolvedCount === 0 && newIssuesCount === 0) {
|
||||
return {
|
||||
status: 'ready_to_merge',
|
||||
label: 'Ready to Merge',
|
||||
description: `All ${resolvedCount} issue${resolvedCount !== 1 ? 's' : ''} resolved. This PR can be merged.`,
|
||||
label: t('prReview.readyToMerge'),
|
||||
description: t('prReview.allIssuesResolved', { count: resolvedCount }),
|
||||
icon: <CheckCheck className="h-5 w-5" />,
|
||||
color: 'bg-success/20 text-success border-success/50',
|
||||
};
|
||||
@@ -456,8 +261,8 @@ export function PRDetail({
|
||||
const suggestionsCount = unresolvedCount + newIssuesCount;
|
||||
return {
|
||||
status: 'ready_to_merge',
|
||||
label: 'Ready to Merge',
|
||||
description: `${resolvedCount} resolved. ${suggestionsCount} non-blocking suggestion${suggestionsCount !== 1 ? 's' : ''} remain.`,
|
||||
label: t('prReview.readyToMerge'),
|
||||
description: t('prReview.nonBlockingSuggestions', { resolved: resolvedCount, suggestions: suggestionsCount }),
|
||||
icon: <CheckCheck className="h-5 w-5" />,
|
||||
color: 'bg-success/20 text-success border-success/50',
|
||||
};
|
||||
@@ -466,8 +271,8 @@ export function PRDetail({
|
||||
// Blocking issues still remain after follow-up
|
||||
return {
|
||||
status: 'followup_issues_remain',
|
||||
label: 'Blocking Issues',
|
||||
description: `${resolvedCount} resolved, ${unresolvedCount} blocking issue${unresolvedCount !== 1 ? 's' : ''} still open.`,
|
||||
label: t('prReview.blockingIssues'),
|
||||
description: t('prReview.blockingIssuesDesc', { resolved: resolvedCount, unresolved: unresolvedCount }),
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
color: 'bg-warning/20 text-warning border-warning/50',
|
||||
};
|
||||
@@ -479,8 +284,8 @@ export function PRDetail({
|
||||
if (hasPosted && hasNewCommits) {
|
||||
return {
|
||||
status: 'ready_for_followup',
|
||||
label: 'Ready for Follow-up',
|
||||
description: `${newCommitCount} new commit${newCommitCount !== 1 ? 's' : ''} since review. Run follow-up to check if issues are resolved.`,
|
||||
label: t('prReview.readyForFollowup'),
|
||||
description: t('prReview.newCommitsSinceReview', { count: newCommitCount }),
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
color: 'bg-info/20 text-info border-info/50',
|
||||
};
|
||||
@@ -490,8 +295,8 @@ export function PRDetail({
|
||||
if (isReadyToMerge && hasPosted) {
|
||||
return {
|
||||
status: 'ready_to_merge',
|
||||
label: 'Ready to Merge',
|
||||
description: 'No blocking issues found. This PR can be merged.',
|
||||
label: t('prReview.readyToMerge'),
|
||||
description: t('prReview.noBlockingIssues'),
|
||||
icon: <CheckCheck className="h-5 w-5" />,
|
||||
color: 'bg-success/20 text-success border-success/50',
|
||||
};
|
||||
@@ -501,8 +306,8 @@ export function PRDetail({
|
||||
if (hasPosted && hasBlockers) {
|
||||
return {
|
||||
status: 'waiting_for_changes',
|
||||
label: 'Waiting for Changes',
|
||||
description: `${totalPosted} finding${totalPosted !== 1 ? 's' : ''} posted. Waiting for contributor to address issues.`,
|
||||
label: t('prReview.waitingForChanges'),
|
||||
description: t('prReview.findingsPostedWaiting', { count: totalPosted }),
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
color: 'bg-warning/20 text-warning border-warning/50',
|
||||
};
|
||||
@@ -512,8 +317,8 @@ export function PRDetail({
|
||||
if (hasPosted && !hasBlockers) {
|
||||
return {
|
||||
status: 'ready_to_merge',
|
||||
label: 'Ready to Merge',
|
||||
description: `${totalPosted} finding${totalPosted !== 1 ? 's' : ''} posted. No blocking issues remain.`,
|
||||
label: t('prReview.readyToMerge'),
|
||||
description: t('prReview.findingsPostedNoBlockers', { count: totalPosted }),
|
||||
icon: <CheckCheck className="h-5 w-5" />,
|
||||
color: 'bg-success/20 text-success border-success/50',
|
||||
};
|
||||
@@ -523,8 +328,8 @@ export function PRDetail({
|
||||
if (hasUnpostedBlockers) {
|
||||
return {
|
||||
status: 'needs_attention',
|
||||
label: 'Needs Attention',
|
||||
description: `${unpostedFindings.length} finding${unpostedFindings.length !== 1 ? 's' : ''} need to be posted to GitHub.`,
|
||||
label: t('prReview.needsAttention'),
|
||||
description: t('prReview.findingsNeedPosting', { count: unpostedFindings.length }),
|
||||
icon: <AlertCircle className="h-5 w-5" />,
|
||||
color: 'bg-destructive/20 text-destructive border-destructive/50',
|
||||
};
|
||||
@@ -533,12 +338,12 @@ export function PRDetail({
|
||||
// Default: Review complete, pending post
|
||||
return {
|
||||
status: 'reviewed_pending_post',
|
||||
label: 'Review Complete',
|
||||
description: `${reviewResult.findings.length} finding${reviewResult.findings.length !== 1 ? 's' : ''} found. Select and post to GitHub.`,
|
||||
label: t('prReview.reviewComplete'),
|
||||
description: t('prReview.findingsFoundSelectPost', { count: reviewResult.findings.length }),
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
color: 'bg-primary/20 text-primary border-primary/50',
|
||||
};
|
||||
}, [reviewResult, postedFindingIds, isReadyToMerge, newCommitsCheck]);
|
||||
}, [reviewResult, postedFindingIds, isReadyToMerge, newCommitsCheck, t]);
|
||||
|
||||
const handlePostReview = async () => {
|
||||
const idsToPost = Array.from(selectedFindingIds);
|
||||
@@ -577,6 +382,53 @@ export function PRDetail({
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-approval for clean PRs - posts LOW findings as suggestions + approval comment
|
||||
// NOTE: GitHub PR comments are intentionally in English as it's the lingua franca
|
||||
// for code reviews and GitHub's international developer community. The comment
|
||||
// content is meant to be read by contributors who may have different locales.
|
||||
const handleAutoApprove = async () => {
|
||||
if (!reviewResult) return;
|
||||
setIsPosting(true);
|
||||
try {
|
||||
// Step 1: Post any LOW findings as non-blocking suggestions
|
||||
const lowFindingIds = lowSeverityFindings.map(f => f.id);
|
||||
if (lowFindingIds.length > 0) {
|
||||
const success = await onPostReview(lowFindingIds);
|
||||
if (!success) {
|
||||
// Failed to post findings, don't proceed with approval
|
||||
return;
|
||||
}
|
||||
// Mark them as posted locally
|
||||
setPostedFindingIds(prev => new Set([...prev, ...lowFindingIds]));
|
||||
}
|
||||
|
||||
// Step 2: Post the approval comment
|
||||
const findingsNote = lowFindingIds.length > 0
|
||||
? `- ${lowFindingIds.length} low-severity suggestion${lowFindingIds.length !== 1 ? 's' : ''} posted above`
|
||||
: '- No issues found';
|
||||
|
||||
const approvalMessage = `## Auto Claude Review - APPROVED
|
||||
|
||||
**Status:** Ready to Merge
|
||||
|
||||
**Summary:** ${reviewResult.summary}
|
||||
|
||||
---
|
||||
**Review Details:**
|
||||
${findingsNote}
|
||||
- Reviewed at: ${formatDate(reviewResult.reviewedAt, i18n.language)}
|
||||
${reviewResult.isFollowupReview ? `- Follow-up review: All previous blocking issues resolved` : ''}
|
||||
|
||||
*This automated review found no blocking issues. The PR can be safely merged.*
|
||||
|
||||
---
|
||||
*Generated by Auto Claude*`;
|
||||
await onPostComment(approvalMessage);
|
||||
} finally {
|
||||
setIsPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMerge = async () => {
|
||||
setIsMerging(true);
|
||||
try {
|
||||
@@ -589,7 +441,7 @@ export function PRDetail({
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
|
||||
|
||||
{/* Refactored Header */}
|
||||
<PRHeader pr={pr} />
|
||||
|
||||
@@ -598,12 +450,13 @@ export function PRDetail({
|
||||
status={prStatus.status}
|
||||
isReviewing={isReviewing}
|
||||
reviewResult={reviewResult}
|
||||
postedCount={postedFindingIds.size + (reviewResult?.postedFindingIds?.length ?? 0)}
|
||||
previousReviewResult={previousReviewResult}
|
||||
postedCount={new Set([...postedFindingIds, ...(reviewResult?.postedFindingIds ?? [])]).size}
|
||||
onRunReview={onRunReview}
|
||||
onRunFollowupReview={onRunFollowupReview}
|
||||
onCancelReview={onCancelReview}
|
||||
newCommitsCheck={newCommitsCheck}
|
||||
lastPostedAt={postSuccess?.timestamp}
|
||||
lastPostedAt={postSuccess?.timestamp || (reviewResult?.postedAt ? new Date(reviewResult.postedAt).getTime() : null)}
|
||||
/>
|
||||
|
||||
{/* Action Bar (Legacy Actions that fit under the tree context) */}
|
||||
@@ -625,6 +478,33 @@ export function PRDetail({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Auto-approve for clean PRs (only LOW findings or no findings) */}
|
||||
{isCleanReview && (
|
||||
<Button
|
||||
onClick={handleAutoApprove}
|
||||
disabled={isPosting}
|
||||
variant="default"
|
||||
className="flex-1 sm:flex-none bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{isPosting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{t('prReview.postingApproval')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCheck className="h-4 w-4 mr-2" />
|
||||
{t('prReview.autoApprovePR')}
|
||||
{hasFindings && lowSeverityFindings.length > 0 && (
|
||||
<span className="ml-1 text-xs opacity-80">
|
||||
{t('prReview.suggestions', { count: lowSeverityFindings.length })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isReadyToMerge && (
|
||||
<>
|
||||
<Button
|
||||
@@ -670,25 +550,24 @@ export function PRDetail({
|
||||
|
||||
{/* Review Result / Findings */}
|
||||
{reviewResult && reviewResult.success && (
|
||||
<Card className="border shadow-sm">
|
||||
<CardHeader className="pb-3 border-b bg-muted/20">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
{reviewResult.isFollowupReview ? (
|
||||
<RefreshCw className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||
)}
|
||||
{reviewResult.isFollowupReview ? 'Follow-up Review Details' : 'AI Analysis Results'}
|
||||
</span>
|
||||
<Badge variant="outline" className={getStatusColor(reviewResult.overallStatus)}>
|
||||
{reviewResult.overallStatus === 'approve' && 'Approve'}
|
||||
{reviewResult.overallStatus === 'request_changes' && 'Changes Requested'}
|
||||
{reviewResult.overallStatus === 'comment' && 'Comment'}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 pt-6">
|
||||
<CollapsibleCard
|
||||
title={reviewResult.isFollowupReview ? t('prReview.followupReviewDetails') : t('prReview.aiAnalysisResults')}
|
||||
icon={reviewResult.isFollowupReview ? (
|
||||
<RefreshCw className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4 text-purple-500" />
|
||||
)}
|
||||
badge={
|
||||
<Badge variant="outline" className={getStatusColor(reviewResult.overallStatus)}>
|
||||
{reviewResult.overallStatus === 'approve' && t('prReview.approve')}
|
||||
{reviewResult.overallStatus === 'request_changes' && t('prReview.changesRequested')}
|
||||
{reviewResult.overallStatus === 'comment' && t('prReview.commented')}
|
||||
</Badge>
|
||||
}
|
||||
open={analysisExpanded}
|
||||
onOpenChange={setAnalysisExpanded}
|
||||
>
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Follow-up Review Resolution Status */}
|
||||
{reviewResult.isFollowupReview && (
|
||||
<div className="flex flex-wrap gap-3 pb-4 border-b border-border/50">
|
||||
@@ -714,7 +593,7 @@ export function PRDetail({
|
||||
)}
|
||||
|
||||
<div className="bg-muted/30 p-4 rounded-lg text-sm text-muted-foreground leading-relaxed">
|
||||
{reviewResult.summary}
|
||||
{reviewResult.summary}
|
||||
</div>
|
||||
|
||||
{/* Interactive Findings with Selection */}
|
||||
@@ -724,8 +603,8 @@ export function PRDetail({
|
||||
postedIds={postedFindingIds}
|
||||
onSelectionChange={setSelectedFindingIds}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)}
|
||||
|
||||
{/* Review Error */}
|
||||
@@ -745,10 +624,8 @@ export function PRDetail({
|
||||
|
||||
{/* Description */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t('prReview.description')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-2">{t('prReview.description')}</h3>
|
||||
<ScrollArea className="h-[400px] w-full rounded-md border p-4 bg-muted/10">
|
||||
{pr.body ? (
|
||||
<pre className="whitespace-pre-wrap text-sm text-muted-foreground font-sans break-words">
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
RefreshCw,
|
||||
X,
|
||||
Filter,
|
||||
Check
|
||||
Check,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Badge } from '../../ui/badge';
|
||||
@@ -49,6 +50,7 @@ const STATUS_OPTIONS: Array<{
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}> = [
|
||||
{ value: 'reviewing', labelKey: 'prReview.reviewing', icon: Loader2, color: 'text-amber-400', bgColor: 'bg-amber-500/20' },
|
||||
{ value: 'not_reviewed', labelKey: 'prReview.notReviewed', icon: Sparkles, color: 'text-slate-500', bgColor: 'bg-slate-500/20' },
|
||||
{ value: 'reviewed', labelKey: 'prReview.reviewed', icon: CheckCircle2, color: 'text-blue-400', bgColor: 'bg-blue-500/20' },
|
||||
{ value: 'posted', labelKey: 'prReview.posted', icon: Send, color: 'text-purple-400', bgColor: 'bg-purple-500/20' },
|
||||
@@ -208,7 +210,7 @@ function FilterDropdown<T extends string>({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
className="max-h-[300px] overflow-y-auto custom-scrollbar p-1"
|
||||
role="listbox"
|
||||
@@ -259,7 +261,7 @@ function FilterDropdown<T extends string>({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{selected.length > 0 && (
|
||||
<div className="p-1 border-t border-border/50 bg-muted/20">
|
||||
<Button
|
||||
@@ -407,4 +409,3 @@ export function PRFilterBar({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ExternalLink, User, Clock, GitBranch, FileDiff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { Button } from '../../ui/button';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { PRData } from '../hooks/useGitHubPRs';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
export interface PRHeaderProps {
|
||||
pr: PRData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern Header Component for PR Details
|
||||
* Shows PR metadata: state, number, title, author, dates, branches, and file stats
|
||||
*/
|
||||
export function PRHeader({ pr }: PRHeaderProps) {
|
||||
const { t, i18n } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={pr.state.toLowerCase() === 'open' ? 'success' : 'secondary'}
|
||||
className={cn(
|
||||
"capitalize px-2.5 py-0.5",
|
||||
pr.state.toLowerCase() === 'open'
|
||||
? "bg-emerald-500/15 text-emerald-500 hover:bg-emerald-500/25 border-emerald-500/20"
|
||||
: ""
|
||||
)}
|
||||
>
|
||||
{t(`prReview.state.${pr.state.toLowerCase()}`)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-sm font-mono">#{pr.number}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
asChild
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<a href={pr.htmlUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-bold mb-4 leading-tight">{pr.title}</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm text-muted-foreground border-b border-border/40 pb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted rounded-full p-1">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span className="font-medium text-foreground">{pr.author.login}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 opacity-70" />
|
||||
<span>{formatDate(pr.createdAt, i18n.language)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/50 font-mono text-xs border border-border/50">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<span className="text-foreground">{pr.headRefName}</span>
|
||||
<span className="text-muted-foreground/50 mx-1">→</span>
|
||||
<span className="text-foreground">{pr.baseRefName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
<div className="flex items-center gap-1.5" title={t('prReview.filesChanged', { count: pr.changedFiles })}>
|
||||
<FileDiff className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">{pr.changedFiles}</span>
|
||||
<span className="text-xs">{t('prReview.files')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-mono">
|
||||
<span className="text-emerald-500 bg-emerald-500/10 px-1.5 py-0.5 rounded">
|
||||
+{pr.additions}
|
||||
</span>
|
||||
<span className="text-red-500 bg-red-500/10 px-1.5 py-0.5 rounded">
|
||||
-{pr.deletions}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GitPullRequest, User, Clock, FileDiff, Loader2, CheckCircle2, AlertCircle, MessageSquare, RefreshCw, Send } from 'lucide-react';
|
||||
import { GitPullRequest, User, Clock, FileDiff } from 'lucide-react';
|
||||
import { ScrollArea } from '../../ui/scroll-area';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { cn } from '../../../lib/utils';
|
||||
@@ -7,88 +7,145 @@ import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Determine the secondary status label for a PR based on its review state
|
||||
* and cached new commits check from the store.
|
||||
* Status Flow Dots Component
|
||||
* Shows 3-dot progression with status label: ● ● ● Ready to Merge
|
||||
*
|
||||
* Status priority:
|
||||
* 1. "Ready for Follow-up" - new commits detected since last review (highest priority)
|
||||
* 2. "Changes Requested" - review posted with blocking issues
|
||||
* 3. "Ready to Merge" - review posted with no blocking issues
|
||||
*
|
||||
* Note: We only show "Changes Requested" or "Ready to Merge" AFTER the review
|
||||
* has been posted to GitHub (reviewId exists or hasPostedFindings is true).
|
||||
* Before posting, we just show "Reviewed" via the primary badge.
|
||||
* States:
|
||||
* - Not started: ○ ○ ○ (gray, no label)
|
||||
* - Reviewing: ● ○ ○ Reviewing (amber, animated)
|
||||
* - Reviewed (pending post): ● ● ○ Pending Post (blue)
|
||||
* - Posted: ● ● ● [Status] (final status color + label)
|
||||
*/
|
||||
function getSecondaryStatus(
|
||||
reviewResult: PRReviewResult | null | undefined,
|
||||
newCommitsCheck: NewCommitsCheck | null | undefined
|
||||
): {
|
||||
type: 'changes_requested' | 'ready_to_merge' | 'ready_for_followup' | 'pending_post' | null;
|
||||
label: string;
|
||||
description?: string;
|
||||
} | null {
|
||||
if (!reviewResult) return null;
|
||||
interface PRStatusFlowProps {
|
||||
isReviewing: boolean;
|
||||
hasResult: boolean;
|
||||
hasPosted: boolean;
|
||||
hasBlockingFindings: boolean;
|
||||
hasNewCommits: boolean;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const hasFindings = reviewResult.findings && reviewResult.findings.length > 0;
|
||||
const hasBlockingFindings = reviewResult.findings?.some(
|
||||
f => f.severity === 'critical' || f.severity === 'high'
|
||||
);
|
||||
// Check if review has been posted to GitHub
|
||||
const hasBeenPosted = Boolean(reviewResult.reviewId) || Boolean(reviewResult.hasPostedFindings);
|
||||
type FlowState = 'not_started' | 'reviewing' | 'reviewed' | 'posted';
|
||||
type FinalStatus = 'success' | 'warning' | 'followup';
|
||||
|
||||
// If we have cached new commits check and there are new commits - ready for follow-up
|
||||
// Only show this if the review was previously posted
|
||||
if (hasBeenPosted && newCommitsCheck?.hasNewCommits && hasFindings) {
|
||||
return {
|
||||
type: 'ready_for_followup',
|
||||
label: 'Ready for Follow-up',
|
||||
description: `${newCommitsCheck.newCommitCount} new commit(s)`
|
||||
};
|
||||
function PRStatusFlow({
|
||||
isReviewing,
|
||||
hasResult,
|
||||
hasPosted,
|
||||
hasBlockingFindings,
|
||||
hasNewCommits,
|
||||
t,
|
||||
}: PRStatusFlowProps) {
|
||||
// Determine flow state - prioritize more advanced states first
|
||||
let flowState: FlowState = 'not_started';
|
||||
if (hasPosted) {
|
||||
// Posted is the most advanced state
|
||||
flowState = 'posted';
|
||||
} else if (hasResult) {
|
||||
// Has result but not posted yet
|
||||
flowState = 'reviewed';
|
||||
} else if (isReviewing) {
|
||||
// Currently reviewing (only if no result yet)
|
||||
flowState = 'reviewing';
|
||||
}
|
||||
|
||||
// Only show status badges AFTER the review has been posted to GitHub
|
||||
if (!hasBeenPosted) {
|
||||
// If there are findings but not yet posted, show "pending post" indicator
|
||||
if (hasFindings) {
|
||||
return {
|
||||
type: 'pending_post',
|
||||
label: 'Pending Post',
|
||||
description: `${reviewResult.findings.length} finding(s) to post`
|
||||
// Determine final status color for posted state
|
||||
let finalStatus: FinalStatus = 'success';
|
||||
if (hasNewCommits) {
|
||||
finalStatus = 'followup';
|
||||
} else if (hasBlockingFindings) {
|
||||
finalStatus = 'warning';
|
||||
}
|
||||
|
||||
// Dot styles based on state
|
||||
const getDotStyle = (dotIndex: 0 | 1 | 2) => {
|
||||
const baseClasses = 'h-2 w-2 rounded-full transition-all duration-300';
|
||||
|
||||
// Not started - all gray
|
||||
if (flowState === 'not_started') {
|
||||
return cn(baseClasses, 'bg-muted-foreground/30');
|
||||
}
|
||||
|
||||
// Reviewing - first dot amber and animated
|
||||
if (flowState === 'reviewing') {
|
||||
if (dotIndex === 0) {
|
||||
return cn(baseClasses, 'bg-amber-400 animate-pulse');
|
||||
}
|
||||
return cn(baseClasses, 'bg-muted-foreground/30');
|
||||
}
|
||||
|
||||
// Reviewed - first two dots filled
|
||||
if (flowState === 'reviewed') {
|
||||
if (dotIndex === 0) {
|
||||
return cn(baseClasses, 'bg-amber-400');
|
||||
}
|
||||
if (dotIndex === 1) {
|
||||
return cn(baseClasses, 'bg-blue-400');
|
||||
}
|
||||
return cn(baseClasses, 'bg-muted-foreground/30');
|
||||
}
|
||||
|
||||
// Posted - all dots filled with final status color
|
||||
if (flowState === 'posted') {
|
||||
const statusColors = {
|
||||
success: 'bg-emerald-400',
|
||||
warning: 'bg-red-400',
|
||||
followup: 'bg-cyan-400',
|
||||
};
|
||||
// First two dots stay with their process colors
|
||||
if (dotIndex === 0) {
|
||||
return cn(baseClasses, 'bg-amber-400');
|
||||
}
|
||||
if (dotIndex === 1) {
|
||||
return cn(baseClasses, 'bg-blue-400');
|
||||
}
|
||||
// Third dot shows final status
|
||||
return cn(baseClasses, statusColors[finalStatus]);
|
||||
}
|
||||
|
||||
return cn(baseClasses, 'bg-muted-foreground/30');
|
||||
};
|
||||
|
||||
// Get status label and styling
|
||||
const getStatusDisplay = (): { label: string; textColor: string } | null => {
|
||||
if (flowState === 'not_started') {
|
||||
return null; // No label for not started
|
||||
}
|
||||
if (flowState === 'reviewing') {
|
||||
return { label: t('prReview.reviewing'), textColor: 'text-amber-400' };
|
||||
}
|
||||
if (flowState === 'reviewed') {
|
||||
return { label: t('prReview.pendingPost'), textColor: 'text-blue-400' };
|
||||
}
|
||||
if (flowState === 'posted') {
|
||||
const statusConfig = {
|
||||
success: { label: t('prReview.readyToMerge'), textColor: 'text-emerald-400' },
|
||||
warning: { label: t('prReview.changesRequested'), textColor: 'text-red-400' },
|
||||
followup: { label: t('prReview.readyForFollowup'), textColor: 'text-cyan-400' },
|
||||
};
|
||||
return statusConfig[finalStatus];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Review has been posted - show appropriate status
|
||||
const statusDisplay = getStatusDisplay();
|
||||
|
||||
// If there are blocking findings (critical/high) - show changes requested
|
||||
if (hasFindings && hasBlockingFindings) {
|
||||
const blockingCount = reviewResult.findings.filter(f => f.severity === 'critical' || f.severity === 'high').length;
|
||||
return {
|
||||
type: 'changes_requested',
|
||||
label: 'Changes Requested',
|
||||
description: `${blockingCount} blocking issue(s)`
|
||||
};
|
||||
}
|
||||
|
||||
// If only non-blocking findings - can merge with suggestions
|
||||
if (hasFindings && !hasBlockingFindings) {
|
||||
return {
|
||||
type: 'ready_to_merge',
|
||||
label: 'Ready to Merge',
|
||||
description: `${reviewResult.findings.length} suggestion(s)`
|
||||
};
|
||||
}
|
||||
|
||||
// No findings - ready to merge
|
||||
if (!hasFindings && reviewResult.success) {
|
||||
return {
|
||||
type: 'ready_to_merge',
|
||||
label: 'Ready to Merge'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Dots */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={getDotStyle(0)} />
|
||||
<div className={getDotStyle(1)} />
|
||||
<div className={getDotStyle(2)} />
|
||||
</div>
|
||||
{/* Label */}
|
||||
{statusDisplay && (
|
||||
<span className={cn('text-xs font-medium', statusDisplay.textColor)}>
|
||||
{statusDisplay.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PRReviewInfo {
|
||||
@@ -170,7 +227,6 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, getReviewState
|
||||
const reviewState = getReviewStateForPR(pr.number);
|
||||
const isReviewingPR = reviewState?.isReviewing ?? false;
|
||||
const hasReviewResult = reviewState?.result !== null && reviewState?.result !== undefined;
|
||||
const secondaryStatus = hasReviewResult ? getSecondaryStatus(reviewState?.result, reviewState?.newCommitsCheck) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -189,73 +245,23 @@ export function PRList({ prs, selectedPRNumber, isLoading, error, getReviewState
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{pr.headRefName}
|
||||
</Badge>
|
||||
{/* Review status indicator */}
|
||||
{isReviewingPR && (
|
||||
<Badge variant="secondary" className="text-xs flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{t('prReview.reviewing')}
|
||||
</Badge>
|
||||
)}
|
||||
{!isReviewingPR && hasReviewResult && reviewState?.result && (
|
||||
<>
|
||||
{/* Show "Reviewed" if AI review is complete but not yet posted to GitHub */}
|
||||
{!reviewState.result.reviewId && !reviewState.result.hasPostedFindings && (
|
||||
<Badge variant="outline" className="text-xs flex items-center gap-1 text-blue-500 border-blue-500/50">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t('prReview.reviewed')}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Show "Posted" when findings posted to GitHub but no full review ID */}
|
||||
{!reviewState.result.reviewId && reviewState.result.hasPostedFindings && (
|
||||
<Badge variant="purple" className="text-xs flex items-center gap-1">
|
||||
<Send className="h-3 w-3" />
|
||||
{t('prReview.posted')}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Show actual status only after posted to GitHub (has reviewId) */}
|
||||
{reviewState.result.reviewId && reviewState.result.overallStatus === 'approve' && (
|
||||
<Badge variant="outline" className="text-xs flex items-center gap-1 text-success border-success/50">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t('prReview.approved')}
|
||||
</Badge>
|
||||
)}
|
||||
{reviewState.result.reviewId && reviewState.result.overallStatus === 'request_changes' && (
|
||||
<Badge variant="outline" className="text-xs flex items-center gap-1 text-destructive border-destructive/50">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{t('prReview.changesRequested')}
|
||||
</Badge>
|
||||
)}
|
||||
{reviewState.result.reviewId && reviewState.result.overallStatus === 'comment' && (
|
||||
<Badge variant="outline" className="text-xs flex items-center gap-1 text-blue-500 border-blue-500/50">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t('prReview.commented')}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Secondary status badge - shows action needed */}
|
||||
{!isReviewingPR && secondaryStatus && (
|
||||
<>
|
||||
{secondaryStatus.type === 'ready_for_followup' && (
|
||||
<Badge className="text-xs flex items-center gap-1 bg-info/20 text-info border-info/50">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{t('prReview.readyForFollowup')}
|
||||
</Badge>
|
||||
)}
|
||||
{secondaryStatus.type === 'pending_post' && (
|
||||
<Badge className="text-xs flex items-center gap-1 bg-muted/50 text-muted-foreground border-muted-foreground/50">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t('prReview.pendingPost')}
|
||||
</Badge>
|
||||
)}
|
||||
{secondaryStatus.type === 'ready_to_merge' && (
|
||||
<Badge className="text-xs flex items-center gap-1 bg-success/20 text-success border-success/50">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t('prReview.readyToMerge')}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Review status flow dots + label */}
|
||||
<PRStatusFlow
|
||||
isReviewing={isReviewingPR}
|
||||
hasResult={hasReviewResult}
|
||||
hasPosted={
|
||||
Boolean(reviewState?.result?.reviewId) ||
|
||||
Boolean(reviewState?.result?.hasPostedFindings) ||
|
||||
Boolean(reviewState?.result?.postedFindingIds?.length) ||
|
||||
// Follow-up review with no new findings to post is effectively "posted"
|
||||
(Boolean(reviewState?.result?.isFollowupReview) && reviewState?.result?.findings?.length === 0)
|
||||
}
|
||||
hasBlockingFindings={Boolean(reviewState?.result?.findings?.some(
|
||||
f => f.severity === 'critical' || f.severity === 'high'
|
||||
))}
|
||||
hasNewCommits={Boolean(reviewState?.newCommitsCheck?.hasNewCommits)}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="font-medium text-sm truncate">{pr.title}</h3>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
CheckSquare,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui/button';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { PRReviewFinding } from '../hooks/useGitHubPRs';
|
||||
@@ -39,6 +40,8 @@ export function ReviewFindings({
|
||||
postedIds = new Set(),
|
||||
onSelectionChange,
|
||||
}: ReviewFindingsProps) {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
// Track which sections are expanded
|
||||
const [expandedSections, setExpandedSections] = useState<Set<SeverityGroup>>(
|
||||
new Set<SeverityGroup>(['critical', 'high']) // Critical and High expanded by default
|
||||
@@ -118,7 +121,7 @@ export function ReviewFindings({
|
||||
disabled={counts.important === 0}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Select Critical/High ({counts.important})
|
||||
{t('prReview.selectCriticalHigh', { count: counts.important })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -127,7 +130,7 @@ export function ReviewFindings({
|
||||
className="text-xs"
|
||||
>
|
||||
<CheckSquare className="h-3 w-3 mr-1" />
|
||||
Select All
|
||||
{t('prReview.selectAll')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -137,7 +140,7 @@ export function ReviewFindings({
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
Clear
|
||||
{t('prReview.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +198,7 @@ export function ReviewFindings({
|
||||
{findings.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<CheckCircle className="h-8 w-8 mx-auto mb-2 text-success" />
|
||||
<p className="text-sm">No issues found! The code looks good.</p>
|
||||
<p className="text-sm">{t('prReview.noIssuesFound')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, Circle, CircleDot, Play } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../ui/button';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { CollapsibleCard } from './CollapsibleCard';
|
||||
import type { PRReviewResult } from '../hooks/useGitHubPRs';
|
||||
import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
export type ReviewStatus =
|
||||
| 'not_reviewed'
|
||||
| 'reviewed_pending_post'
|
||||
| 'waiting_for_changes'
|
||||
| 'ready_to_merge'
|
||||
| 'needs_attention'
|
||||
| 'ready_for_followup'
|
||||
| 'followup_issues_remain';
|
||||
|
||||
export interface ReviewStatusTreeProps {
|
||||
status: ReviewStatus;
|
||||
isReviewing: boolean;
|
||||
reviewResult: PRReviewResult | null;
|
||||
previousReviewResult: PRReviewResult | null;
|
||||
postedCount: number;
|
||||
onRunReview: () => void;
|
||||
onRunFollowupReview: () => void;
|
||||
onCancelReview: () => void;
|
||||
newCommitsCheck: NewCommitsCheck | null;
|
||||
lastPostedAt?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact Tree View for Review Process
|
||||
* Shows the current status and history of a PR review
|
||||
*/
|
||||
export function ReviewStatusTree({
|
||||
status,
|
||||
isReviewing,
|
||||
reviewResult,
|
||||
previousReviewResult,
|
||||
postedCount,
|
||||
onRunReview,
|
||||
onRunFollowupReview,
|
||||
onCancelReview,
|
||||
newCommitsCheck,
|
||||
lastPostedAt
|
||||
}: ReviewStatusTreeProps) {
|
||||
const { t, i18n } = useTranslation('common');
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
// Determine if this is a follow-up review in progress (for edge case handling)
|
||||
const isFollowupInProgress = isReviewing && (previousReviewResult !== null || reviewResult?.isFollowupReview);
|
||||
|
||||
// If not reviewed, show simple status
|
||||
if (status === 'not_reviewed' && !isReviewing) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-y-3 p-4 border rounded-lg bg-card shadow-sm">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded-full bg-muted-foreground/30" />
|
||||
<span className="font-medium text-muted-foreground truncate">{t('prReview.notReviewed')}</span>
|
||||
</div>
|
||||
<Button onClick={onRunReview} size="sm" className="gap-2 shrink-0 ml-auto sm:ml-0">
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
{t('prReview.runAIReview')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine steps for the tree
|
||||
const steps: { id: string; label: string; status: string; date?: string | null; action?: React.ReactNode }[] = [];
|
||||
|
||||
// When follow-up is in progress, show continuation (handle edge case where previousReviewResult may be null)
|
||||
if (isFollowupInProgress) {
|
||||
// Show previous review as completed context (if available)
|
||||
if (previousReviewResult) {
|
||||
steps.push({
|
||||
id: 'prev_review',
|
||||
label: t('prReview.previousReview', { count: previousReviewResult.findings.length }),
|
||||
status: 'completed',
|
||||
date: previousReviewResult.reviewedAt
|
||||
});
|
||||
|
||||
// Show posted findings from previous review
|
||||
const prevPostedCount = previousReviewResult.postedFindingIds?.length ?? 0;
|
||||
if (previousReviewResult.hasPostedFindings || prevPostedCount > 0) {
|
||||
steps.push({
|
||||
id: 'prev_posted',
|
||||
label: t('prReview.findingsPosted', { count: prevPostedCount }),
|
||||
status: 'completed',
|
||||
date: previousReviewResult.postedAt
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Edge case: Follow-up review starting but previous result hasn't loaded yet
|
||||
steps.push({
|
||||
id: 'prev_review',
|
||||
label: t('prReview.reviewStatus'),
|
||||
status: 'completed',
|
||||
date: null
|
||||
});
|
||||
}
|
||||
|
||||
// Show new commits that triggered follow-up
|
||||
if (newCommitsCheck?.hasNewCommits) {
|
||||
steps.push({
|
||||
id: 'new_commits',
|
||||
label: t('prReview.newCommits', { count: newCommitsCheck.newCommitCount }),
|
||||
status: 'completed',
|
||||
date: null
|
||||
});
|
||||
}
|
||||
|
||||
// Show follow-up in progress
|
||||
steps.push({
|
||||
id: 'followup_analysis',
|
||||
label: t('prReview.followupInProgress'),
|
||||
status: 'current',
|
||||
date: null
|
||||
});
|
||||
} else {
|
||||
// Original logic for initial review or completed follow-up
|
||||
|
||||
// Step 1: Start
|
||||
steps.push({
|
||||
id: 'start',
|
||||
label: t('prReview.reviewStarted'),
|
||||
status: 'completed',
|
||||
date: reviewResult?.reviewedAt || new Date().toISOString()
|
||||
});
|
||||
|
||||
// Step 2: AI Analysis
|
||||
if (isReviewing) {
|
||||
steps.push({
|
||||
id: 'analysis',
|
||||
label: t('prReview.analysisInProgress'),
|
||||
status: 'current',
|
||||
date: null
|
||||
});
|
||||
} else if (reviewResult) {
|
||||
steps.push({
|
||||
id: 'analysis',
|
||||
label: t('prReview.analysisComplete', { count: reviewResult.findings.length }),
|
||||
status: 'completed',
|
||||
date: reviewResult.reviewedAt
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Posting
|
||||
if (postedCount > 0 || reviewResult?.hasPostedFindings) {
|
||||
steps.push({
|
||||
id: 'posted',
|
||||
label: t('prReview.findingsPostedToGitHub'),
|
||||
status: 'completed',
|
||||
date: reviewResult?.postedAt || (lastPostedAt ? new Date(lastPostedAt).toISOString() : null)
|
||||
});
|
||||
} else if (reviewResult && reviewResult.findings.length > 0) {
|
||||
steps.push({
|
||||
id: 'posted',
|
||||
label: t('prReview.pendingPost'),
|
||||
status: 'pending',
|
||||
date: null
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4: Follow-up (only show when not currently reviewing)
|
||||
if (!isReviewing && newCommitsCheck?.hasNewCommits) {
|
||||
steps.push({
|
||||
id: 'new_commits',
|
||||
label: t('prReview.newCommits', { count: newCommitsCheck.newCommitCount }),
|
||||
status: 'alert',
|
||||
date: null
|
||||
});
|
||||
steps.push({
|
||||
id: 'followup',
|
||||
label: t('prReview.readyForFollowup'),
|
||||
status: 'pending',
|
||||
action: (
|
||||
<Button size="sm" variant="outline" onClick={onRunFollowupReview} className="ml-2 h-6 text-xs px-2">
|
||||
{t('prReview.runFollowup')}
|
||||
</Button>
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Status dot color - explicitly handle all statuses
|
||||
const getStatusDotColor = (): string => {
|
||||
if (isReviewing) return "bg-blue-500 animate-pulse";
|
||||
switch (status) {
|
||||
case 'ready_to_merge':
|
||||
return "bg-success";
|
||||
case 'waiting_for_changes':
|
||||
return "bg-warning";
|
||||
case 'reviewed_pending_post':
|
||||
return "bg-primary";
|
||||
case 'ready_for_followup':
|
||||
return "bg-info";
|
||||
case 'needs_attention':
|
||||
return "bg-destructive";
|
||||
case 'followup_issues_remain':
|
||||
return "bg-warning";
|
||||
default:
|
||||
return "bg-muted-foreground";
|
||||
}
|
||||
};
|
||||
const statusDotColor = cn("h-2.5 w-2.5 shrink-0 rounded-full", getStatusDotColor());
|
||||
|
||||
// Status label - explicitly handle all statuses
|
||||
const getStatusLabel = (): string => {
|
||||
if (isReviewing) return t('prReview.aiReviewInProgress');
|
||||
switch (status) {
|
||||
case 'ready_to_merge':
|
||||
return t('prReview.readyToMerge');
|
||||
case 'waiting_for_changes':
|
||||
return t('prReview.waitingForChanges');
|
||||
case 'reviewed_pending_post':
|
||||
return t('prReview.reviewComplete');
|
||||
case 'ready_for_followup':
|
||||
return t('prReview.readyForFollowup');
|
||||
case 'needs_attention':
|
||||
return t('prReview.needsAttention');
|
||||
case 'followup_issues_remain':
|
||||
return t('prReview.blockingIssues');
|
||||
default:
|
||||
return t('prReview.reviewStatus');
|
||||
}
|
||||
};
|
||||
const statusLabel = getStatusLabel();
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={statusLabel}
|
||||
icon={<div className={statusDotColor} />}
|
||||
headerAction={isReviewing ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); onCancelReview(); }}
|
||||
className="h-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
{t('prReview.cancel')}
|
||||
</Button>
|
||||
) : undefined}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<div className="p-4 pt-0">
|
||||
<div className="relative pl-2 ml-2 border-l border-border/50 space-y-4 pt-4">
|
||||
{steps.map((step) => (
|
||||
<div key={step.id} className="relative flex items-start gap-3 pl-4">
|
||||
{/* Node Dot */}
|
||||
<div className={cn("absolute -left-[13px] top-1 bg-background rounded-full p-0.5 border",
|
||||
step.status === 'completed' ? "border-success text-success" :
|
||||
step.status === 'current' ? "border-primary text-primary animate-pulse" :
|
||||
step.status === 'alert' ? "border-warning text-warning" :
|
||||
"border-muted-foreground text-muted-foreground"
|
||||
)}>
|
||||
{step.status === 'completed' ? <CheckCircle className="h-3 w-3" /> :
|
||||
step.status === 'current' ? <CircleDot className="h-3 w-3" /> :
|
||||
<Circle className="h-3 w-3" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className={cn("text-sm font-medium truncate max-w-full",
|
||||
step.status === 'completed' ? "text-foreground" :
|
||||
step.status === 'current' ? "text-primary" :
|
||||
"text-muted-foreground"
|
||||
)}>
|
||||
{step.label}
|
||||
</span>
|
||||
{step.action}
|
||||
</div>
|
||||
{step.date && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatDate(step.date, i18n.language)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { ChevronDown, ChevronRight, CheckSquare, Square, MinusSquare } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '../../ui/badge';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { SeverityGroup } from '../constants/severity-config';
|
||||
@@ -25,6 +26,7 @@ export function SeverityGroupHeader({
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
}: SeverityGroupHeaderProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const config = SEVERITY_CONFIG[severity];
|
||||
const Icon = config.icon;
|
||||
const isFullySelected = selectedCount === count && count > 0;
|
||||
@@ -53,13 +55,13 @@ export function SeverityGroupHeader({
|
||||
|
||||
<Icon className={cn("h-4 w-4", config.color)} />
|
||||
<span className={cn("font-medium text-sm", config.color)}>
|
||||
{config.label}
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{count}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
{config.description}
|
||||
{t(config.descriptionKey)}
|
||||
</span>
|
||||
</div>
|
||||
{expanded ? (
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
// Reusable UI components
|
||||
export { CollapsibleCard } from './CollapsibleCard';
|
||||
export type { CollapsibleCardProps } from './CollapsibleCard';
|
||||
|
||||
// PR Detail sub-components
|
||||
export { ReviewStatusTree } from './ReviewStatusTree';
|
||||
export type { ReviewStatusTreeProps, ReviewStatus } from './ReviewStatusTree';
|
||||
|
||||
export { PRHeader } from './PRHeader';
|
||||
export type { PRHeaderProps } from './PRHeader';
|
||||
|
||||
// Main components
|
||||
export { PRList } from './PRList';
|
||||
export { PRDetail } from './PRDetail';
|
||||
export { PRFilterBar } from './PRFilterBar';
|
||||
export { ReviewFindings } from './ReviewFindings';
|
||||
|
||||
@@ -19,39 +19,39 @@ export type SeverityGroup = 'critical' | 'high' | 'medium' | 'low';
|
||||
export const SEVERITY_ORDER: SeverityGroup[] = ['critical', 'high', 'medium', 'low'];
|
||||
|
||||
export const SEVERITY_CONFIG: Record<SeverityGroup, {
|
||||
label: string;
|
||||
labelKey: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
icon: typeof XCircle;
|
||||
description: string;
|
||||
descriptionKey: string;
|
||||
}> = {
|
||||
critical: {
|
||||
label: 'Critical',
|
||||
labelKey: 'prReview.severity.critical',
|
||||
color: 'text-red-500',
|
||||
bgColor: 'bg-red-500/10 border-red-500/30',
|
||||
icon: XCircle,
|
||||
description: 'Must fix before merge',
|
||||
descriptionKey: 'prReview.severity.criticalDesc',
|
||||
},
|
||||
high: {
|
||||
label: 'High',
|
||||
labelKey: 'prReview.severity.high',
|
||||
color: 'text-orange-500',
|
||||
bgColor: 'bg-orange-500/10 border-orange-500/30',
|
||||
icon: AlertTriangle,
|
||||
description: 'Should fix before merge',
|
||||
descriptionKey: 'prReview.severity.highDesc',
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
labelKey: 'prReview.severity.medium',
|
||||
color: 'text-yellow-500',
|
||||
bgColor: 'bg-yellow-500/10 border-yellow-500/30',
|
||||
icon: AlertCircle,
|
||||
description: 'Consider fixing',
|
||||
descriptionKey: 'prReview.severity.mediumDesc',
|
||||
},
|
||||
low: {
|
||||
label: 'Low',
|
||||
labelKey: 'prReview.severity.low',
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10 border-blue-500/30',
|
||||
icon: CheckCircle,
|
||||
description: 'Nice to have',
|
||||
descriptionKey: 'prReview.severity.lowDesc',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ interface UseGitHubPRsResult {
|
||||
postComment: (prNumber: number, body: string) => Promise<boolean>;
|
||||
mergePR: (prNumber: number, mergeMethod?: 'merge' | 'squash' | 'rebase') => Promise<boolean>;
|
||||
assignPR: (prNumber: number, username: string) => Promise<boolean>;
|
||||
getReviewStateForPR: (prNumber: number) => { isReviewing: boolean; progress: PRReviewProgress | null; result: PRReviewResult | null; error: string | null } | null;
|
||||
getReviewStateForPR: (prNumber: number) => { isReviewing: boolean; progress: PRReviewProgress | null; result: PRReviewResult | null; previousResult: PRReviewResult | null; error: string | null; newCommitsCheck?: NewCommitsCheck | null } | null;
|
||||
}
|
||||
|
||||
export function useGitHubPRs(projectId?: string): UseGitHubPRsResult {
|
||||
@@ -75,6 +75,7 @@ export function useGitHubPRs(projectId?: string): UseGitHubPRsResult {
|
||||
isReviewing: state.isReviewing,
|
||||
progress: state.progress,
|
||||
result: state.result,
|
||||
previousResult: state.previousResult,
|
||||
error: state.error,
|
||||
newCommitsCheck: state.newCommitsCheck
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { PRData, PRReviewResult } from '../../../../preload/api/modules/git
|
||||
import type { NewCommitsCheck } from '../../../../preload/api/modules/github-api';
|
||||
|
||||
export type PRStatusFilter =
|
||||
| 'all'
|
||||
| 'reviewing'
|
||||
| 'not_reviewed'
|
||||
| 'reviewed'
|
||||
| 'posted'
|
||||
@@ -38,6 +40,11 @@ const DEFAULT_FILTERS: PRFilterState = {
|
||||
function getPRComputedStatus(
|
||||
reviewInfo: PRReviewInfo | null
|
||||
): PRStatusFilter {
|
||||
// Check if currently reviewing (highest priority)
|
||||
if (reviewInfo?.isReviewing) {
|
||||
return 'reviewing';
|
||||
}
|
||||
|
||||
if (!reviewInfo?.result) {
|
||||
return 'not_reviewed';
|
||||
}
|
||||
@@ -64,10 +71,7 @@ function getPRComputedStatus(
|
||||
return 'ready_to_merge';
|
||||
}
|
||||
|
||||
// Has review result but not yet posted to GitHub
|
||||
// Note: 'posted' is not returned here - it's a meta-filter that matches
|
||||
// any posted state (ready_to_merge, changes_requested, ready_for_followup)
|
||||
// and is handled specially in the filter logic below
|
||||
// Has review result but not posted yet
|
||||
return 'reviewed';
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Helper function for formatting dates with validation and locale support
|
||||
* @param dateString - ISO date string to format
|
||||
* @param locale - Locale for formatting (defaults to 'en-US')
|
||||
* @returns Formatted date string or empty string if invalid
|
||||
*/
|
||||
export function formatDate(dateString: string, locale: string = 'en-US'): string {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
return date.toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
* - Min/max width constraints
|
||||
* - Persists width to localStorage
|
||||
* - Visual feedback on hover and drag
|
||||
* - Touch support for mobile devices
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react';
|
||||
|
||||
@@ -14,6 +14,8 @@ interface PRReviewState {
|
||||
isReviewing: boolean;
|
||||
progress: PRReviewProgress | null;
|
||||
result: PRReviewResult | null;
|
||||
/** Previous review result - preserved during follow-up review for continuity */
|
||||
previousResult: PRReviewResult | null;
|
||||
error: string | null;
|
||||
/** Cached result of new commits check - updated when detail view checks */
|
||||
newCommitsCheck: NewCommitsCheck | null;
|
||||
@@ -26,6 +28,7 @@ interface PRReviewStoreState {
|
||||
|
||||
// Actions
|
||||
startPRReview: (projectId: string, prNumber: number) => void;
|
||||
startFollowupReview: (projectId: string, prNumber: number) => void;
|
||||
setPRReviewProgress: (projectId: string, progress: PRReviewProgress) => void;
|
||||
setPRReviewResult: (projectId: string, result: PRReviewResult) => void;
|
||||
setPRReviewError: (projectId: string, prNumber: number, error: string) => void;
|
||||
@@ -54,6 +57,36 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
isReviewing: true,
|
||||
progress: null,
|
||||
result: null,
|
||||
previousResult: null,
|
||||
error: null,
|
||||
newCommitsCheck: existing?.newCommitsCheck ?? null
|
||||
}
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
startFollowupReview: (projectId: string, prNumber: number) => set((state) => {
|
||||
const key = `${projectId}:${prNumber}`;
|
||||
const existing = state.prReviews[key];
|
||||
|
||||
// Log warning if starting follow-up without a previous result
|
||||
if (!existing?.result) {
|
||||
console.warn(
|
||||
`[PRReviewStore] Starting follow-up review for PR #${prNumber} without a previous result. ` +
|
||||
`This may indicate the follow-up was triggered incorrectly.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
prReviews: {
|
||||
...state.prReviews,
|
||||
[key]: {
|
||||
prNumber,
|
||||
projectId,
|
||||
isReviewing: true,
|
||||
progress: null,
|
||||
result: null,
|
||||
previousResult: existing?.result ?? null, // Preserve for follow-up continuity
|
||||
error: null,
|
||||
newCommitsCheck: existing?.newCommitsCheck ?? null
|
||||
}
|
||||
@@ -73,6 +106,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
isReviewing: true,
|
||||
progress,
|
||||
result: existing?.result ?? null,
|
||||
previousResult: existing?.previousResult ?? null,
|
||||
error: null,
|
||||
newCommitsCheck: existing?.newCommitsCheck ?? null
|
||||
}
|
||||
@@ -82,6 +116,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
|
||||
setPRReviewResult: (projectId: string, result: PRReviewResult) => set((state) => {
|
||||
const key = `${projectId}:${result.prNumber}`;
|
||||
const existing = state.prReviews[key];
|
||||
return {
|
||||
prReviews: {
|
||||
...state.prReviews,
|
||||
@@ -91,6 +126,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
isReviewing: false,
|
||||
progress: null,
|
||||
result,
|
||||
previousResult: existing?.previousResult ?? null,
|
||||
error: result.error ?? null,
|
||||
// Clear new commits check when review completes (it was just reviewed)
|
||||
newCommitsCheck: null
|
||||
@@ -111,6 +147,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
isReviewing: false,
|
||||
progress: null,
|
||||
result: existing?.result ?? null,
|
||||
previousResult: existing?.previousResult ?? null,
|
||||
error,
|
||||
newCommitsCheck: existing?.newCommitsCheck ?? null
|
||||
}
|
||||
@@ -132,6 +169,7 @@ export const usePRReviewStore = create<PRReviewStoreState>((set, get) => ({
|
||||
isReviewing: false,
|
||||
progress: null,
|
||||
result: null,
|
||||
previousResult: null,
|
||||
error: null,
|
||||
newCommitsCheck: check
|
||||
}
|
||||
@@ -219,9 +257,10 @@ export function startPRReview(projectId: string, prNumber: number): void {
|
||||
|
||||
/**
|
||||
* Start a follow-up PR review and track it in the store
|
||||
* Uses startFollowupReview action to preserve previous result for continuity
|
||||
*/
|
||||
export function startFollowupReview(projectId: string, prNumber: number): void {
|
||||
const store = usePRReviewStore.getState();
|
||||
store.startPRReview(projectId, prNumber);
|
||||
store.startFollowupReview(projectId, prNumber);
|
||||
window.electronAPI.github.runFollowupReview(projectId, prNumber);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"analysisComplete": "Analysis Complete ({{count}} findings)",
|
||||
"findingsPostedToGitHub": "Findings Posted to GitHub",
|
||||
"newCommits": "{{count}} New Commits",
|
||||
"newCommit": "{{count}} New Commit",
|
||||
"runFollowup": "Run Follow-up",
|
||||
"aiReviewInProgress": "AI Review in Progress",
|
||||
"waitingForChanges": "Waiting for Changes",
|
||||
@@ -135,10 +136,13 @@
|
||||
"files": "files",
|
||||
"filesChanged": "{{count}} files changed",
|
||||
"posting": "Posting...",
|
||||
"postingApproval": "Posting Approval...",
|
||||
"postFindings": "Post {{count}} Finding",
|
||||
"postFindings_plural": "Post {{count}} Findings",
|
||||
"approve": "Approve",
|
||||
"merge": "Merge",
|
||||
"autoApprovePR": "Auto-Approve PR",
|
||||
"suggestions": "+{{count}} suggestions",
|
||||
"postedFindings": "Posted {{count}} finding",
|
||||
"postedFindings_plural": "Posted {{count}} findings",
|
||||
"resolved": "{{count}} resolved",
|
||||
@@ -149,7 +153,66 @@
|
||||
"newIssue_plural": "{{count}} new issues",
|
||||
"reviewFailed": "Review Failed",
|
||||
"description": "Description",
|
||||
"noDescription": "No description provided."
|
||||
"noDescription": "No description provided.",
|
||||
"followupReviewDetails": "Follow-up Review Details",
|
||||
"aiAnalysisResults": "AI Analysis Results",
|
||||
"cancel": "Cancel",
|
||||
"previousReview": "Previous Review ({{count}} findings)",
|
||||
"findingsPosted": "{{count}} Posted",
|
||||
"followupInProgress": "Follow-up Analysis in Progress...",
|
||||
"severity": {
|
||||
"critical": "Critical",
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low",
|
||||
"criticalDesc": "Must fix before merge",
|
||||
"highDesc": "Should fix before merge",
|
||||
"mediumDesc": "Consider fixing",
|
||||
"lowDesc": "Nice to have"
|
||||
},
|
||||
"category": {
|
||||
"security": "Security",
|
||||
"logic": "Logic",
|
||||
"quality": "Quality",
|
||||
"performance": "Performance",
|
||||
"style": "Style",
|
||||
"documentation": "Documentation",
|
||||
"testing": "Testing",
|
||||
"other": "Other"
|
||||
},
|
||||
"state": {
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"merged": "Merged"
|
||||
},
|
||||
"selectCriticalHigh": "Select Critical/High ({{count}})",
|
||||
"selectAll": "Select All",
|
||||
"clear": "Clear",
|
||||
"noIssuesFound": "No issues found! The code looks good.",
|
||||
"selectedOfTotal": "{{selected}}/{{total}} selected",
|
||||
"suggestedFix": "Suggested fix:",
|
||||
"runAIReviewDesc": "Run an AI review to analyze this PR",
|
||||
"newCommitsSinceFollowup": "{{count}} new commit since follow-up. Run another follow-up review.",
|
||||
"newCommitsSinceFollowup_plural": "{{count}} new commits since follow-up. Run another follow-up review.",
|
||||
"allIssuesResolved": "All {{count}} issue resolved. This PR can be merged.",
|
||||
"allIssuesResolved_plural": "All {{count}} issues resolved. This PR can be merged.",
|
||||
"nonBlockingSuggestions": "{{resolved}} resolved. {{suggestions}} non-blocking suggestion remain.",
|
||||
"nonBlockingSuggestions_plural": "{{resolved}} resolved. {{suggestions}} non-blocking suggestions remain.",
|
||||
"blockingIssues": "Blocking Issues",
|
||||
"blockingIssuesDesc": "{{resolved}} resolved, {{unresolved}} blocking issue still open.",
|
||||
"blockingIssuesDesc_plural": "{{resolved}} resolved, {{unresolved}} blocking issues still open.",
|
||||
"newCommitsSinceReview": "{{count}} new commit since review. Run follow-up to check if issues are resolved.",
|
||||
"newCommitsSinceReview_plural": "{{count}} new commits since review. Run follow-up to check if issues are resolved.",
|
||||
"noBlockingIssues": "No blocking issues found. This PR can be merged.",
|
||||
"findingsPostedWaiting": "{{count}} finding posted. Waiting for contributor to address issues.",
|
||||
"findingsPostedWaiting_plural": "{{count}} findings posted. Waiting for contributor to address issues.",
|
||||
"findingsPostedNoBlockers": "{{count}} finding posted. No blocking issues remain.",
|
||||
"findingsPostedNoBlockers_plural": "{{count}} findings posted. No blocking issues remain.",
|
||||
"needsAttention": "Needs Attention",
|
||||
"findingsNeedPosting": "{{count}} finding need to be posted to GitHub.",
|
||||
"findingsNeedPosting_plural": "{{count}} findings need to be posted to GitHub.",
|
||||
"findingsFoundSelectPost": "{{count}} finding found. Select and post to GitHub.",
|
||||
"findingsFoundSelectPost_plural": "{{count}} findings found. Select and post to GitHub."
|
||||
},
|
||||
"downloads": {
|
||||
"toggleExpand": "Toggle download details",
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
"analysisComplete": "Analyse terminée ({{count}} résultats)",
|
||||
"findingsPostedToGitHub": "Résultats publiés sur GitHub",
|
||||
"newCommits": "{{count}} nouveaux commits",
|
||||
"newCommit": "{{count}} nouveau commit",
|
||||
"runFollowup": "Lancer le suivi",
|
||||
"aiReviewInProgress": "Révision IA en cours",
|
||||
"waitingForChanges": "En attente de modifications",
|
||||
@@ -135,10 +136,13 @@
|
||||
"files": "fichiers",
|
||||
"filesChanged": "{{count}} fichiers modifiés",
|
||||
"posting": "Publication...",
|
||||
"postingApproval": "Publication de l'approbation...",
|
||||
"postFindings": "Publier {{count}} résultat",
|
||||
"postFindings_plural": "Publier {{count}} résultats",
|
||||
"approve": "Approuver",
|
||||
"merge": "Fusionner",
|
||||
"autoApprovePR": "Auto-approuver PR",
|
||||
"suggestions": "+{{count}} suggestions",
|
||||
"postedFindings": "{{count}} résultat publié",
|
||||
"postedFindings_plural": "{{count}} résultats publiés",
|
||||
"resolved": "{{count}} résolu",
|
||||
@@ -149,7 +153,66 @@
|
||||
"newIssue_plural": "{{count}} nouveaux problèmes",
|
||||
"reviewFailed": "Révision échouée",
|
||||
"description": "Description",
|
||||
"noDescription": "Aucune description fournie."
|
||||
"noDescription": "Aucune description fournie.",
|
||||
"followupReviewDetails": "Détails de la révision de suivi",
|
||||
"aiAnalysisResults": "Résultats de l'analyse IA",
|
||||
"cancel": "Annuler",
|
||||
"previousReview": "Révision précédente ({{count}} résultats)",
|
||||
"findingsPosted": "{{count}} publiés",
|
||||
"followupInProgress": "Analyse de suivi en cours...",
|
||||
"severity": {
|
||||
"critical": "Critique",
|
||||
"high": "Élevée",
|
||||
"medium": "Moyenne",
|
||||
"low": "Faible",
|
||||
"criticalDesc": "Doit être corrigé avant fusion",
|
||||
"highDesc": "Devrait être corrigé avant fusion",
|
||||
"mediumDesc": "À considérer",
|
||||
"lowDesc": "Optionnel"
|
||||
},
|
||||
"category": {
|
||||
"security": "Sécurité",
|
||||
"logic": "Logique",
|
||||
"quality": "Qualité",
|
||||
"performance": "Performance",
|
||||
"style": "Style",
|
||||
"documentation": "Documentation",
|
||||
"testing": "Tests",
|
||||
"other": "Autre"
|
||||
},
|
||||
"state": {
|
||||
"open": "Ouvert",
|
||||
"closed": "Fermé",
|
||||
"merged": "Fusionné"
|
||||
},
|
||||
"selectCriticalHigh": "Sélectionner Critique/Élevée ({{count}})",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"clear": "Effacer",
|
||||
"noIssuesFound": "Aucun problème trouvé ! Le code est bon.",
|
||||
"selectedOfTotal": "{{selected}}/{{total}} sélectionnés",
|
||||
"suggestedFix": "Correction suggérée :",
|
||||
"runAIReviewDesc": "Lancez une révision IA pour analyser cette PR",
|
||||
"newCommitsSinceFollowup": "{{count}} nouveau commit depuis le suivi. Lancez un autre suivi.",
|
||||
"newCommitsSinceFollowup_plural": "{{count}} nouveaux commits depuis le suivi. Lancez un autre suivi.",
|
||||
"allIssuesResolved": "{{count}} problème résolu. Cette PR peut être fusionnée.",
|
||||
"allIssuesResolved_plural": "Tous les {{count}} problèmes résolus. Cette PR peut être fusionnée.",
|
||||
"nonBlockingSuggestions": "{{resolved}} résolus. {{suggestions}} suggestion non bloquante restante.",
|
||||
"nonBlockingSuggestions_plural": "{{resolved}} résolus. {{suggestions}} suggestions non bloquantes restantes.",
|
||||
"blockingIssues": "Problèmes bloquants",
|
||||
"blockingIssuesDesc": "{{resolved}} résolus, {{unresolved}} problème bloquant encore ouvert.",
|
||||
"blockingIssuesDesc_plural": "{{resolved}} résolus, {{unresolved}} problèmes bloquants encore ouverts.",
|
||||
"newCommitsSinceReview": "{{count}} nouveau commit depuis la révision. Lancez un suivi pour vérifier.",
|
||||
"newCommitsSinceReview_plural": "{{count}} nouveaux commits depuis la révision. Lancez un suivi pour vérifier.",
|
||||
"noBlockingIssues": "Aucun problème bloquant trouvé. Cette PR peut être fusionnée.",
|
||||
"findingsPostedWaiting": "{{count}} résultat publié. En attente des modifications du contributeur.",
|
||||
"findingsPostedWaiting_plural": "{{count}} résultats publiés. En attente des modifications du contributeur.",
|
||||
"findingsPostedNoBlockers": "{{count}} résultat publié. Aucun problème bloquant.",
|
||||
"findingsPostedNoBlockers_plural": "{{count}} résultats publiés. Aucun problème bloquant.",
|
||||
"needsAttention": "Nécessite attention",
|
||||
"findingsNeedPosting": "{{count}} résultat doit être publié sur GitHub.",
|
||||
"findingsNeedPosting_plural": "{{count}} résultats doivent être publiés sur GitHub.",
|
||||
"findingsFoundSelectPost": "{{count}} résultat trouvé. Sélectionnez et publiez sur GitHub.",
|
||||
"findingsFoundSelectPost_plural": "{{count}} résultats trouvés. Sélectionnez et publiez sur GitHub."
|
||||
},
|
||||
"downloads": {
|
||||
"toggleExpand": "Afficher/masquer les détails",
|
||||
|
||||
Reference in New Issue
Block a user