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:
Alex
2025-12-30 17:05:10 +01:00
committed by GitHub
parent 515b73b553
commit bdb0154977
20 changed files with 1081 additions and 514 deletions
@@ -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",