| |
| |
| |
| |
| |
|
|
| import { useState, useCallback } from 'react'; |
| import { |
| GitPullRequest, |
| RefreshCw, |
| ExternalLink, |
| GitMerge, |
| X, |
| MessageSquare, |
| MoreHorizontal, |
| Zap, |
| ArrowLeft, |
| } from 'lucide-react'; |
| import { Spinner } from '@/components/ui/spinner'; |
| import { getElectronAPI, type GitHubPR } from '@/lib/electron'; |
| import { useAppStore, type Feature } from '@/store/app-store'; |
| import { Button } from '@/components/ui/button'; |
| import { Markdown } from '@/components/ui/markdown'; |
| import { cn, generateUUID } from '@/lib/utils'; |
| import { useIsMobile } from '@/hooks/use-media-query'; |
| import { useGitHubPRs } from '@/hooks/queries'; |
| import { useCreateFeature } from '@/hooks/mutations/use-feature-mutations'; |
| import { PRCommentResolutionDialog } from '@/components/dialogs'; |
| import { resolveModelString } from '@automaker/model-resolver'; |
| import { toast } from 'sonner'; |
| import { |
| DropdownMenu, |
| DropdownMenuContent, |
| DropdownMenuItem, |
| DropdownMenuSeparator, |
| DropdownMenuTrigger, |
| } from '@/components/ui/dropdown-menu'; |
|
|
| export function GitHubPRsView() { |
| const [selectedPR, setSelectedPR] = useState<GitHubPR | null>(null); |
| const [commentDialogPR, setCommentDialogPR] = useState<GitHubPR | null>(null); |
| const { currentProject, getEffectiveUseWorktrees } = useAppStore(); |
| const isMobile = useIsMobile(); |
|
|
| const { |
| data, |
| isLoading: loading, |
| isFetching: refreshing, |
| error, |
| refetch, |
| } = useGitHubPRs(currentProject?.path); |
|
|
| const openPRs = data?.openPRs ?? []; |
| const mergedPRs = data?.mergedPRs ?? []; |
|
|
| const handleRefresh = useCallback(() => { |
| refetch(); |
| }, [refetch]); |
|
|
| const handleOpenInGitHub = useCallback((url: string) => { |
| const api = getElectronAPI(); |
| api.openExternalLink(url); |
| }, []); |
|
|
| const createFeature = useCreateFeature(currentProject?.path ?? ''); |
|
|
| const handleAutoAddressComments = useCallback( |
| async (pr: GitHubPR) => { |
| if (!pr.number || !currentProject?.path) { |
| toast.error('Cannot address PR comments', { |
| description: 'No PR number or project available.', |
| }); |
| return; |
| } |
|
|
| const featureId = `pr-${pr.number}-${generateUUID()}`; |
| const feature: Feature = { |
| id: featureId, |
| title: `Address PR #${pr.number} Review Comments`, |
| category: 'bug-fix', |
| description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`, |
| steps: [], |
| status: 'backlog', |
| model: resolveModelString('opus'), |
| thinkingLevel: 'none', |
| planningMode: 'skip', |
| requirePlanApproval: false, |
| dependencies: [], |
| ...(pr.url ? { prUrl: pr.url } : {}), |
| ...(pr.headRefName ? { branchName: pr.headRefName } : {}), |
| }; |
|
|
| try { |
| await createFeature.mutateAsync(feature); |
|
|
| |
| const api = getElectronAPI(); |
| if (api.autoMode?.runFeature) { |
| try { |
| await api.autoMode.runFeature( |
| currentProject.path, |
| featureId, |
| getEffectiveUseWorktrees(currentProject.path) |
| ); |
| toast.success('Feature created and started', { |
| description: `Addressing review comments on PR #${pr.number}`, |
| }); |
| } catch (runError) { |
| toast.error('Feature created but failed to start', { |
| description: |
| runError instanceof Error |
| ? runError.message |
| : 'An error occurred while starting the feature', |
| }); |
| } |
| } else { |
| toast.error('Cannot start feature', { |
| description: |
| 'Feature API is not available. The feature was created but could not be started.', |
| }); |
| } |
| } catch (error) { |
| toast.error('Failed to create feature', { |
| description: error instanceof Error ? error.message : 'An error occurred', |
| }); |
| } |
| }, |
| [currentProject, createFeature, getEffectiveUseWorktrees] |
| ); |
|
|
| const formatDate = (dateString: string) => { |
| const date = new Date(dateString); |
| return date.toLocaleDateString('en-US', { |
| month: 'short', |
| day: 'numeric', |
| year: 'numeric', |
| }); |
| }; |
|
|
| const getReviewStatus = (pr: GitHubPR) => { |
| if (pr.isDraft) return { label: 'Draft', color: 'text-muted-foreground', bg: 'bg-muted' }; |
| switch (pr.reviewDecision) { |
| case 'APPROVED': |
| return { label: 'Approved', color: 'text-green-500', bg: 'bg-green-500/10' }; |
| case 'CHANGES_REQUESTED': |
| return { label: 'Changes requested', color: 'text-orange-500', bg: 'bg-orange-500/10' }; |
| case 'REVIEW_REQUIRED': |
| return { label: 'Review required', color: 'text-yellow-500', bg: 'bg-yellow-500/10' }; |
| default: |
| return null; |
| } |
| }; |
|
|
| if (loading) { |
| return ( |
| <div className="flex-1 flex items-center justify-center"> |
| <Spinner size="xl" /> |
| </div> |
| ); |
| } |
|
|
| if (error) { |
| return ( |
| <div className="flex-1 flex flex-col items-center justify-center text-center p-6"> |
| <div className="p-4 rounded-full bg-destructive/10 mb-4"> |
| <GitPullRequest className="h-12 w-12 text-destructive" /> |
| </div> |
| <h2 className="text-lg font-medium mb-2">Failed to Load Pull Requests</h2> |
| <p className="text-muted-foreground max-w-md mb-4"> |
| {error instanceof Error ? error.message : 'Failed to fetch pull requests'} |
| </p> |
| <Button variant="outline" onClick={handleRefresh}> |
| <RefreshCw className="h-4 w-4 mr-2" /> |
| Try Again |
| </Button> |
| </div> |
| ); |
| } |
|
|
| const totalPRs = openPRs.length + mergedPRs.length; |
|
|
| return ( |
| <div className="flex-1 flex overflow-hidden"> |
| {/* PR List - hidden on mobile when a PR is selected */} |
| <div |
| className={cn( |
| 'flex flex-col overflow-hidden border-r border-border', |
| selectedPR ? 'w-80' : 'flex-1', |
| isMobile && selectedPR && 'hidden' |
| )} |
| > |
| {/* Header */} |
| <div className="flex items-center justify-between p-4 border-b border-border"> |
| <div className="flex items-center gap-3"> |
| <div className="p-2 rounded-lg bg-blue-500/10"> |
| <GitPullRequest className="h-5 w-5 text-blue-500" /> |
| </div> |
| <div> |
| <h1 className="text-lg font-bold">Pull Requests</h1> |
| <p className="text-xs text-muted-foreground"> |
| {totalPRs === 0 |
| ? 'No pull requests found' |
| : `${openPRs.length} open, ${mergedPRs.length} merged`} |
| </p> |
| </div> |
| </div> |
| <Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}> |
| {refreshing ? <Spinner size="sm" /> : <RefreshCw className="h-4 w-4" />} |
| </Button> |
| </div> |
| |
| {/* PR List */} |
| <div className="flex-1 overflow-auto"> |
| {totalPRs === 0 ? ( |
| <div className="flex flex-col items-center justify-center h-full text-center p-6"> |
| <div className="p-4 rounded-full bg-muted/50 mb-4"> |
| <GitPullRequest className="h-8 w-8 text-muted-foreground" /> |
| </div> |
| <h2 className="text-base font-medium mb-2">No Pull Requests</h2> |
| <p className="text-sm text-muted-foreground"> |
| This repository has no pull requests yet. |
| </p> |
| </div> |
| ) : ( |
| <div className="divide-y divide-border"> |
| {/* Open PRs */} |
| {openPRs.map((pr) => ( |
| <PRRow |
| key={pr.number} |
| pr={pr} |
| isSelected={selectedPR?.number === pr.number} |
| onClick={() => setSelectedPR(pr)} |
| onOpenExternal={() => handleOpenInGitHub(pr.url)} |
| onManageComments={() => setCommentDialogPR(pr)} |
| onAutoAddressComments={() => handleAutoAddressComments(pr)} |
| formatDate={formatDate} |
| getReviewStatus={getReviewStatus} |
| /> |
| ))} |
| |
| {/* Merged PRs Section */} |
| {mergedPRs.length > 0 && ( |
| <> |
| <div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground"> |
| Merged ({mergedPRs.length}) |
| </div> |
| {mergedPRs.map((pr) => ( |
| <PRRow |
| key={pr.number} |
| pr={pr} |
| isSelected={selectedPR?.number === pr.number} |
| onClick={() => setSelectedPR(pr)} |
| onOpenExternal={() => handleOpenInGitHub(pr.url)} |
| onManageComments={() => setCommentDialogPR(pr)} |
| onAutoAddressComments={() => handleAutoAddressComments(pr)} |
| formatDate={formatDate} |
| getReviewStatus={getReviewStatus} |
| /> |
| ))} |
| </> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
|
|
| {} |
| {selectedPR && |
| (() => { |
| const reviewStatus = getReviewStatus(selectedPR); |
| return ( |
| <div className="flex-1 flex flex-col overflow-hidden"> |
| {/* Detail Header */} |
| <div className="flex items-center justify-between p-3 border-b border-border bg-muted/30 gap-2"> |
| <div className="flex items-center gap-2 min-w-0"> |
| {isMobile && ( |
| <Button |
| variant="ghost" |
| size="sm" |
| onClick={() => setSelectedPR(null)} |
| className="shrink-0 -ml-1" |
| > |
| <ArrowLeft className="h-4 w-4" /> |
| </Button> |
| )} |
| {selectedPR.state === 'MERGED' ? ( |
| <GitMerge className="h-4 w-4 text-purple-500 shrink-0" /> |
| ) : ( |
| <GitPullRequest className="h-4 w-4 text-green-500 shrink-0" /> |
| )} |
| <span className="text-sm font-medium truncate"> |
| #{selectedPR.number} {selectedPR.title} |
| </span> |
| {selectedPR.isDraft && ( |
| <span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground"> |
| Draft |
| </span> |
| )} |
| </div> |
| <div className={cn('flex items-center gap-2 shrink-0', isMobile && 'gap-1')}> |
| {!isMobile && ( |
| <Button |
| variant="outline" |
| size="sm" |
| onClick={() => setCommentDialogPR(selectedPR)} |
| > |
| <MessageSquare className="h-4 w-4 mr-1" /> |
| Manage Comments |
| </Button> |
| )} |
| <Button |
| variant="outline" |
| size="sm" |
| onClick={() => handleOpenInGitHub(selectedPR.url)} |
| > |
| <ExternalLink className="h-4 w-4" /> |
| {!isMobile && <span className="ml-1">Open in GitHub</span>} |
| </Button> |
| {!isMobile && ( |
| <Button variant="ghost" size="sm" onClick={() => setSelectedPR(null)}> |
| <X className="h-4 w-4" /> |
| </Button> |
| )} |
| </div> |
| </div> |
| |
| {/* PR Detail Content */} |
| <div className={cn('flex-1 overflow-auto', isMobile ? 'p-4' : 'p-6')}> |
| {/* Title */} |
| <h1 className="text-xl font-bold mb-2">{selectedPR.title}</h1> |
| |
| {/* Meta info */} |
| <div className="flex items-center gap-3 text-sm text-muted-foreground mb-4 flex-wrap"> |
| <span |
| className={cn( |
| 'px-2 py-0.5 rounded-full text-xs font-medium', |
| selectedPR.state === 'MERGED' |
| ? 'bg-purple-500/10 text-purple-500' |
| : selectedPR.isDraft |
| ? 'bg-muted text-muted-foreground' |
| : 'bg-green-500/10 text-green-500' |
| )} |
| > |
| {selectedPR.state === 'MERGED' |
| ? 'Merged' |
| : selectedPR.isDraft |
| ? 'Draft' |
| : 'Open'} |
| </span> |
| {reviewStatus && ( |
| <span |
| className={cn( |
| 'px-2 py-0.5 rounded-full text-xs font-medium', |
| reviewStatus.bg, |
| reviewStatus.color |
| )} |
| > |
| {reviewStatus.label} |
| </span> |
| )} |
| <span> |
| #{selectedPR.number} opened {formatDate(selectedPR.createdAt)} by{' '} |
| <span className="font-medium text-foreground">{selectedPR.author.login}</span> |
| </span> |
| </div> |
| |
| {/* Branch info */} |
| {selectedPR.headRefName && ( |
| <div className="flex items-center gap-2 mb-4"> |
| <span className="text-xs text-muted-foreground">Branch:</span> |
| <span className="text-xs font-mono bg-muted px-2 py-0.5 rounded"> |
| {selectedPR.headRefName} |
| </span> |
| </div> |
| )} |
| |
| {/* Labels */} |
| {selectedPR.labels.length > 0 && ( |
| <div className="flex items-center gap-2 mb-6 flex-wrap"> |
| {selectedPR.labels.map((label) => ( |
| <span |
| key={label.name} |
| className="px-2 py-0.5 text-xs font-medium rounded-full" |
| style={{ |
| backgroundColor: `#${label.color}20`, |
| color: `#${label.color}`, |
| border: `1px solid #${label.color}40`, |
| }} |
| > |
| {label.name} |
| </span> |
| ))} |
| </div> |
| )} |
| |
| {/* Body */} |
| {selectedPR.body ? ( |
| <Markdown className="text-sm">{selectedPR.body}</Markdown> |
| ) : ( |
| <p className="text-sm text-muted-foreground italic">No description provided.</p> |
| )} |
| |
| {/* Review Comments CTA */} |
| <div className="mt-8 p-4 rounded-lg bg-blue-500/5 border border-blue-500/20"> |
| <div className="flex items-center gap-2 mb-2"> |
| <MessageSquare className="h-4 w-4 text-blue-500" /> |
| <span className="text-sm font-medium">Review Comments</span> |
| </div> |
| <p className="text-sm text-muted-foreground mb-3"> |
| Manage review comments individually or let AI address all feedback |
| automatically. |
| </p> |
| <div className={cn('flex gap-2', isMobile ? 'flex-col' : 'items-center')}> |
| <Button variant="outline" onClick={() => setCommentDialogPR(selectedPR)}> |
| <MessageSquare className="h-4 w-4 mr-2" /> |
| Manage Review Comments |
| </Button> |
| <Button variant="outline" onClick={() => handleAutoAddressComments(selectedPR)}> |
| <Zap className="h-4 w-4 mr-2" /> |
| Address Review Comments |
| </Button> |
| </div> |
| </div> |
| |
| {/* Open in GitHub CTA */} |
| <div className="mt-4 p-4 rounded-lg bg-muted/50 border border-border"> |
| <p className="text-sm text-muted-foreground mb-3"> |
| View code changes, comments, and reviews on GitHub. |
| </p> |
| <Button onClick={() => handleOpenInGitHub(selectedPR.url)}> |
| <ExternalLink className="h-4 w-4 mr-2" /> |
| View Full PR on GitHub |
| </Button> |
| </div> |
| </div> |
| </div> |
| ); |
| })()} |
|
|
| {} |
| {commentDialogPR && ( |
| <PRCommentResolutionDialog |
| open={!!commentDialogPR} |
| onOpenChange={(open) => { |
| if (!open) setCommentDialogPR(null); |
| }} |
| pr={commentDialogPR} |
| /> |
| )} |
| </div> |
| ); |
| } |
|
|
| interface PRRowProps { |
| pr: GitHubPR; |
| isSelected: boolean; |
| onClick: () => void; |
| onOpenExternal: () => void; |
| onManageComments: () => void; |
| onAutoAddressComments: () => void; |
| formatDate: (date: string) => string; |
| getReviewStatus: (pr: GitHubPR) => { label: string; color: string; bg: string } | null; |
| } |
|
|
| function PRRow({ |
| pr, |
| isSelected, |
| onClick, |
| onOpenExternal, |
| onManageComments, |
| onAutoAddressComments, |
| formatDate, |
| getReviewStatus, |
| }: PRRowProps) { |
| const reviewStatus = getReviewStatus(pr); |
|
|
| return ( |
| <div |
| className={cn( |
| 'flex items-start gap-3 p-3 cursor-pointer hover:bg-accent/50 transition-colors', |
| isSelected && 'bg-accent' |
| )} |
| onClick={onClick} |
| > |
| {pr.state === 'MERGED' ? ( |
| <GitMerge className="h-4 w-4 text-purple-500 mt-0.5 shrink-0" /> |
| ) : ( |
| <GitPullRequest className="h-4 w-4 text-green-500 mt-0.5 shrink-0" /> |
| )} |
| |
| <div className="flex-1 min-w-0"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-medium truncate">{pr.title}</span> |
| {pr.isDraft && ( |
| <span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground shrink-0"> |
| Draft |
| </span> |
| )} |
| </div> |
| |
| <div className="flex items-center gap-2 mt-1 flex-wrap"> |
| <span className="text-xs text-muted-foreground"> |
| #{pr.number} opened {formatDate(pr.createdAt)} by {pr.author.login} |
| </span> |
| {pr.headRefName && ( |
| <span className="text-xs text-muted-foreground font-mono bg-muted px-1 rounded"> |
| {pr.headRefName} |
| </span> |
| )} |
| </div> |
| |
| <div className="flex items-center gap-2 mt-2 flex-wrap"> |
| {/* Review Status */} |
| {reviewStatus && ( |
| <span |
| className={cn( |
| 'px-1.5 py-0.5 text-[10px] font-medium rounded', |
| reviewStatus.bg, |
| reviewStatus.color |
| )} |
| > |
| {reviewStatus.label} |
| </span> |
| )} |
| |
| {/* Labels */} |
| {pr.labels.map((label) => ( |
| <span |
| key={label.name} |
| className="px-1.5 py-0.5 text-[10px] font-medium rounded-full" |
| style={{ |
| backgroundColor: `#${label.color}20`, |
| color: `#${label.color}`, |
| border: `1px solid #${label.color}40`, |
| }} |
| > |
| {label.name} |
| </span> |
| ))} |
| </div> |
| </div> |
| |
| {/* Actions dropdown menu */} |
| <DropdownMenu> |
| <DropdownMenuTrigger asChild> |
| <Button |
| variant="ghost" |
| size="sm" |
| className="shrink-0 h-7 w-7 p-0" |
| onClick={(e) => e.stopPropagation()} |
| > |
| <MoreHorizontal className="h-3.5 w-3.5" /> |
| </Button> |
| </DropdownMenuTrigger> |
| <DropdownMenuContent align="end" className="w-48"> |
| <DropdownMenuItem |
| onClick={(e) => { |
| e.stopPropagation(); |
| onManageComments(); |
| }} |
| className="text-xs text-blue-500 focus:text-blue-600" |
| > |
| <MessageSquare className="h-3.5 w-3.5 mr-2" /> |
| Manage PR Comments |
| </DropdownMenuItem> |
| <DropdownMenuItem |
| onClick={(e) => { |
| e.stopPropagation(); |
| onAutoAddressComments(); |
| }} |
| className="text-xs text-blue-500 focus:text-blue-600" |
| > |
| <Zap className="h-3.5 w-3.5 mr-2" /> |
| Address PR Comments |
| </DropdownMenuItem> |
| <DropdownMenuSeparator /> |
| <DropdownMenuItem |
| onClick={(e) => { |
| e.stopPropagation(); |
| onOpenExternal(); |
| }} |
| className="text-xs" |
| > |
| <ExternalLink className="h-3.5 w-3.5 mr-2" /> |
| Open in GitHub |
| </DropdownMenuItem> |
| </DropdownMenuContent> |
| </DropdownMenu> |
| </div> |
| ); |
| } |
|
|