diff --git a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx index 56b77117..9e99b799 100644 --- a/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx +++ b/apps/frontend/src/renderer/components/github-prs/GitHubPRs.tsx @@ -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) { 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 ( + + +
+
+
+ {isOpen ? ( + + ) : ( + + )} +
+ {icon &&
{icon}
} + {title} +
+
+ {headerAction} + {badge} +
+
+
+ + {children} + +
+ ); +} diff --git a/apps/frontend/src/renderer/components/github-prs/components/FindingItem.tsx b/apps/frontend/src/renderer/components/github-prs/components/FindingItem.tsx index b11c37cf..f3be2e42 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/FindingItem.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/FindingItem.tsx @@ -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 = { + '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 (
- {finding.category} + {categoryLabel} {posted && ( - Posted + {t('prReview.posted')} )} @@ -69,7 +91,7 @@ export function FindingItem({ finding, selected, posted = false, onToggle }: Fin {/* Suggested Fix */} {finding.suggestedFix && (
- Suggested fix: + {t('prReview.suggestedFix')}
             {finding.suggestedFix}
           
diff --git a/apps/frontend/src/renderer/components/github-prs/components/FindingsSummary.tsx b/apps/frontend/src/renderer/components/github-prs/components/FindingsSummary.tsx index b27c8516..2259b35a 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/FindingsSummary.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/FindingsSummary.tsx @@ -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
{counts.critical > 0 && ( - {counts.critical} Critical + {counts.critical} {t('prReview.severity.critical')} )} {counts.high > 0 && ( - {counts.high} High + {counts.high} {t('prReview.severity.high')} )} {counts.medium > 0 && ( - {counts.medium} Medium + {counts.medium} {t('prReview.severity.medium')} )} {counts.low > 0 && ( - {counts.low} Low + {counts.low} {t('prReview.severity.low')} )}
- {selectedCount}/{counts.total} selected + {t('prReview.selectedOfTotal', { selected: selectedCount, total: counts.total })}
); diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx index 16318a2b..25f5f263 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRDetail.tsx @@ -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; @@ -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 ( -
-
-
- {t('prReview.notReviewed')} -
- -
- ); - } - - // 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: ( - - ) - }); - } - - return ( - - {/* Header / Status Bar */} -
-
-
- - {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')} - -
-
- {isReviewing && ( - - )} - - - -
-
- - {/* Collapsible Tree */} - -
-
- {steps.map((step) => ( -
- {/* Node Dot */} -
- {step.status === 'completed' ? : - step.status === 'current' ? : - } -
- -
-
- - {step.label} - - {step.action} -
- {step.date && ( -
- {formatDate(step.date)} -
- )} -
-
- ))} -
-
-
- - ); -} - -// Modern Header Component -function PRHeader({ pr }: { pr: PRData }) { - const { t } = useTranslation('common'); - return ( -
-
-
- - {pr.state} - - #{pr.number} -
- -
- -

{pr.title}

- -
-
-
- -
- {pr.author.login} -
- -
- - {formatDate(pr.createdAt)} -
- -
- - {pr.headRefName} - - {pr.baseRefName} -
- -
-
- - {pr.changedFiles} - {t('prReview.files')} -
-
- +{pr.additions} - -{pr.deletions} -
-
-
-
- ); -} - 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>(new Set()); const [postedFindingIds, setPostedFindingIds] = useState>(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(null); - const [, setIsCheckingNewCommits] = useState(false); + // Initialize with store value, then sync and update via local checks + const [newCommitsCheck, setNewCommitsCheck] = useState(initialNewCommitsCheck ?? null); + const [analysisExpanded, setAnalysisExpanded] = useState(true); + const checkNewCommitsAbortRef = useRef(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: , + label: t('prReview.notReviewed'), + description: t('prReview.runAIReviewDesc'), + icon: , 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: , 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: , 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: , 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: , 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: , 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: , 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: , 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: , 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: , 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: , 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 (
- + {/* Refactored Header */} @@ -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({ )} + {/* Auto-approve for clean PRs (only LOW findings or no findings) */} + {isCleanReview && ( + + )} + {isReadyToMerge && ( <>
- +
({ }) )}
- + {selected.length > 0 && (
+
+ +

{pr.title}

+ +
+
+
+ +
+ {pr.author.login} +
+ +
+ + {formatDate(pr.createdAt, i18n.language)} +
+ +
+ + {pr.headRefName} + + {pr.baseRefName} +
+ +
+
+ + {pr.changedFiles} + {t('prReview.files')} +
+
+ + +{pr.additions} + + + -{pr.deletions} + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx index 1a311924..69c887ff 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/PRList.tsx @@ -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 ( +
+ {/* Dots */} +
+
+
+
+
+ {/* Label */} + {statusDisplay && ( + + {statusDisplay.label} + + )} +
+ ); } 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 (

{pr.title}

diff --git a/apps/frontend/src/renderer/components/github-prs/components/ReviewFindings.tsx b/apps/frontend/src/renderer/components/github-prs/components/ReviewFindings.tsx index 45dedbd3..0ac08253 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/ReviewFindings.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/ReviewFindings.tsx @@ -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>( new Set(['critical', 'high']) // Critical and High expanded by default @@ -118,7 +121,7 @@ export function ReviewFindings({ disabled={counts.important === 0} > - Select Critical/High ({counts.important}) + {t('prReview.selectCriticalHigh', { count: counts.important })}
@@ -195,7 +198,7 @@ export function ReviewFindings({ {findings.length === 0 && (
-

No issues found! The code looks good.

+

{t('prReview.noIssuesFound')}

)}
diff --git a/apps/frontend/src/renderer/components/github-prs/components/ReviewStatusTree.tsx b/apps/frontend/src/renderer/components/github-prs/components/ReviewStatusTree.tsx new file mode 100644 index 00000000..2293d453 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-prs/components/ReviewStatusTree.tsx @@ -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 ( +
+
+
+ {t('prReview.notReviewed')} +
+ +
+ ); + } + + // 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: ( + + ) + }); + } + } + + // 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 ( + } + headerAction={isReviewing ? ( + + ) : undefined} + open={isOpen} + onOpenChange={setIsOpen} + > +
+
+ {steps.map((step) => ( +
+ {/* Node Dot */} +
+ {step.status === 'completed' ? : + step.status === 'current' ? : + } +
+ +
+
+ + {step.label} + + {step.action} +
+ {step.date && ( +
+ {formatDate(step.date, i18n.language)} +
+ )} +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/github-prs/components/SeverityGroupHeader.tsx b/apps/frontend/src/renderer/components/github-prs/components/SeverityGroupHeader.tsx index 3435ce06..d7ceec49 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/SeverityGroupHeader.tsx +++ b/apps/frontend/src/renderer/components/github-prs/components/SeverityGroupHeader.tsx @@ -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({ - {config.label} + {t(config.labelKey)} {count} - {config.description} + {t(config.descriptionKey)}
{expanded ? ( diff --git a/apps/frontend/src/renderer/components/github-prs/components/index.ts b/apps/frontend/src/renderer/components/github-prs/components/index.ts index bac9986b..736719af 100644 --- a/apps/frontend/src/renderer/components/github-prs/components/index.ts +++ b/apps/frontend/src/renderer/components/github-prs/components/index.ts @@ -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'; diff --git a/apps/frontend/src/renderer/components/github-prs/constants/severity-config.ts b/apps/frontend/src/renderer/components/github-prs/constants/severity-config.ts index 55482dec..c3938d00 100644 --- a/apps/frontend/src/renderer/components/github-prs/constants/severity-config.ts +++ b/apps/frontend/src/renderer/components/github-prs/constants/severity-config.ts @@ -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 = { 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', }, }; diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts index fe91183d..54867550 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/useGitHubPRs.ts @@ -33,7 +33,7 @@ interface UseGitHubPRsResult { postComment: (prNumber: number, body: string) => Promise; mergePR: (prNumber: number, mergeMethod?: 'merge' | 'squash' | 'rebase') => Promise; assignPR: (prNumber: number, username: string) => Promise; - 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 }; diff --git a/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts b/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts index 9bdf77e5..c362cc97 100644 --- a/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts +++ b/apps/frontend/src/renderer/components/github-prs/hooks/usePRFiltering.ts @@ -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'; } diff --git a/apps/frontend/src/renderer/components/github-prs/utils/formatDate.ts b/apps/frontend/src/renderer/components/github-prs/utils/formatDate.ts new file mode 100644 index 00000000..ac21219f --- /dev/null +++ b/apps/frontend/src/renderer/components/github-prs/utils/formatDate.ts @@ -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', + }); +} diff --git a/apps/frontend/src/renderer/components/ui/resizable-panels.tsx b/apps/frontend/src/renderer/components/ui/resizable-panels.tsx index 198abcfe..aa6293a7 100644 --- a/apps/frontend/src/renderer/components/ui/resizable-panels.tsx +++ b/apps/frontend/src/renderer/components/ui/resizable-panels.tsx @@ -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'; diff --git a/apps/frontend/src/renderer/stores/github/pr-review-store.ts b/apps/frontend/src/renderer/stores/github/pr-review-store.ts index dbf60058..74b96771 100644 --- a/apps/frontend/src/renderer/stores/github/pr-review-store.ts +++ b/apps/frontend/src/renderer/stores/github/pr-review-store.ts @@ -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((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((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((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((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((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((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); } diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index c89b058a..73a9b6b8 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -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", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index a6e45f3f..abbd826e 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -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",