| |
| import { useState, useCallback, useMemo } from 'react'; |
| import { createLogger } from '@automaker/utils/logger'; |
| import { CircleDot, RefreshCw, SearchX } from 'lucide-react'; |
| import { useQueryClient } from '@tanstack/react-query'; |
| import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron'; |
| import { useAppStore } from '@/store/app-store'; |
| import { Button } from '@/components/ui/button'; |
| import { ConfirmDialog } from '@/components/ui/confirm-dialog'; |
| import { LoadingState } from '@/components/ui/loading-state'; |
| import { ErrorState } from '@/components/ui/error-state'; |
| import { cn, pathsEqual, generateUUID } from '@/lib/utils'; |
| import { useIsMobile } from '@/hooks/use-media-query'; |
| import { toast } from 'sonner'; |
| import { queryKeys } from '@/lib/query-keys'; |
| import { useGithubIssues, useIssueValidation, useIssuesFilter } from './github-issues-view/hooks'; |
| import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components'; |
| import { ValidationDialog } from './github-issues-view/dialogs'; |
| import { AddFeatureDialog } from './board-view/dialogs'; |
| import { formatDate, getFeaturePriority } from './github-issues-view/utils'; |
| import { resolveModelString } from '@automaker/model-resolver'; |
| import { useModelOverride } from '@/components/shared'; |
| import type { |
| ValidateIssueOptions, |
| IssuesFilterState, |
| IssuesStateFilter, |
| } from './github-issues-view/types'; |
| import { DEFAULT_ISSUES_FILTER_STATE } from './github-issues-view/types'; |
|
|
| const logger = createLogger('GitHubIssuesView'); |
|
|
| export function GitHubIssuesView() { |
| const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null); |
| const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null); |
| const [showValidationDialog, setShowValidationDialog] = useState(false); |
| const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false); |
| const [pendingRevalidateOptions, setPendingRevalidateOptions] = |
| useState<ValidateIssueOptions | null>(null); |
|
|
| |
| const [showAddFeatureDialog, setShowAddFeatureDialog] = useState(false); |
| const [createFeatureIssue, setCreateFeatureIssue] = useState<GitHubIssue | null>(null); |
|
|
| |
| const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE); |
|
|
| const { currentProject, getCurrentWorktree, worktreesByProject, defaultSkipTests } = |
| useAppStore(); |
| const queryClient = useQueryClient(); |
|
|
| |
| const validationModelOverride = useModelOverride({ phase: 'validationModel' }); |
|
|
| const isMobile = useIsMobile(); |
|
|
| const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues(); |
|
|
| const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } = |
| useIssueValidation({ |
| selectedIssue, |
| showValidationDialog, |
| onValidationResultChange: setValidationResult, |
| onShowValidationDialogChange: setShowValidationDialog, |
| }); |
|
|
| |
| const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]); |
|
|
| |
| const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations); |
|
|
| |
| |
| const { filteredOpenIssues, filteredClosedIssues } = useMemo(() => { |
| const open: typeof openIssues = []; |
| const closed: typeof closedIssues = []; |
| for (const issue of filterResult.matchedIssues) { |
| if (issue.state.toLowerCase() === 'open') { |
| open.push(issue); |
| } else { |
| closed.push(issue); |
| } |
| } |
| return { filteredOpenIssues: open, filteredClosedIssues: closed }; |
| }, [filterResult.matchedIssues]); |
|
|
| |
| const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => { |
| setFilterState((prev) => ({ ...prev, stateFilter })); |
| }, []); |
|
|
| const handleLabelsChange = useCallback((selectedLabels: string[]) => { |
| setFilterState((prev) => ({ ...prev, selectedLabels })); |
| }, []); |
|
|
| |
| const handleClearFilters = useCallback(() => { |
| setFilterState(DEFAULT_ISSUES_FILTER_STATE); |
| }, []); |
|
|
| |
| const currentBranch = useMemo(() => { |
| if (!currentProject?.path) return ''; |
| const currentWorktreeInfo = getCurrentWorktree(currentProject.path); |
| const worktrees = worktreesByProject[currentProject.path] ?? []; |
| const currentWorktreePath = currentWorktreeInfo?.path ?? null; |
|
|
| const selectedWorktree = |
| currentWorktreePath === null |
| ? worktrees.find((w) => w.isMain) |
| : worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)); |
|
|
| return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || ''; |
| }, [currentProject?.path, getCurrentWorktree, worktreesByProject]); |
|
|
| const handleOpenInGitHub = useCallback((url: string) => { |
| const api = getElectronAPI(); |
| api.openExternalLink(url); |
| }, []); |
|
|
| |
| const buildIssueDescription = useCallback( |
| (issue: GitHubIssue) => { |
| const parts = [ |
| `**From GitHub Issue #${issue.number}**`, |
| '', |
| issue.body || 'No description provided.', |
| ]; |
|
|
| |
| if (issue.labels.length > 0) { |
| parts.push('', `**Labels:** ${issue.labels.map((l) => l.name).join(', ')}`); |
| } |
|
|
| |
| if (issue.linkedPRs && issue.linkedPRs.length > 0) { |
| parts.push( |
| '', |
| '**Linked Pull Requests:**', |
| ...issue.linkedPRs.map((pr) => `- #${pr.number}: ${pr.title} (${pr.state})`) |
| ); |
| } |
|
|
| |
| const cached = cachedValidations.get(issue.number); |
| if (cached?.result) { |
| const validation = cached.result; |
| parts.push('', '---', '', '**AI Validation Analysis:**', validation.reasoning); |
| if (validation.suggestedFix) { |
| parts.push('', `**Suggested Approach:**`, validation.suggestedFix); |
| } |
| if (validation.relatedFiles?.length) { |
| parts.push('', '**Related Files:**', ...validation.relatedFiles.map((f) => `- \`${f}\``)); |
| } |
| } |
|
|
| return parts.join('\n'); |
| }, |
| [cachedValidations] |
| ); |
|
|
| |
| const prefilledDescription = useMemo( |
| () => (createFeatureIssue ? buildIssueDescription(createFeatureIssue) : undefined), |
| [createFeatureIssue, buildIssueDescription] |
| ); |
|
|
| |
| const handleCreateFeature = useCallback((issue: GitHubIssue) => { |
| setCreateFeatureIssue(issue); |
| setShowAddFeatureDialog(true); |
| }, []); |
|
|
| |
| const handleAddFeatureFromIssue = useCallback( |
| async (featureData: { |
| title: string; |
| category: string; |
| description: string; |
| priority: number; |
| model: string; |
| thinkingLevel: string; |
| reasoningEffort: string; |
| skipTests: boolean; |
| branchName: string; |
| planningMode: string; |
| requirePlanApproval: boolean; |
| excludedPipelineSteps?: string[]; |
| workMode: string; |
| imagePaths?: Array<{ id: string; path: string; description?: string }>; |
| textFilePaths?: Array<{ id: string; path: string; description?: string }>; |
| }) => { |
| if (!currentProject?.path) { |
| toast.error('No project selected'); |
| return; |
| } |
|
|
| try { |
| const api = getElectronAPI(); |
| if (api.features?.create) { |
| const feature = { |
| id: `issue-${createFeatureIssue?.number || 'new'}-${generateUUID()}`, |
| title: featureData.title, |
| description: featureData.description, |
| category: featureData.category, |
| status: 'backlog' as const, |
| passes: false, |
| priority: featureData.priority, |
| model: featureData.model, |
| thinkingLevel: featureData.thinkingLevel, |
| reasoningEffort: featureData.reasoningEffort, |
| providerId: featureData.providerId, |
| skipTests: featureData.skipTests, |
| branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName, |
| planningMode: featureData.planningMode, |
| requirePlanApproval: featureData.requirePlanApproval, |
| dependencies: [], |
| excludedPipelineSteps: featureData.excludedPipelineSteps, |
| ...(featureData.imagePaths?.length ? { imagePaths: featureData.imagePaths } : {}), |
| ...(featureData.textFilePaths?.length |
| ? { textFilePaths: featureData.textFilePaths } |
| : {}), |
| createdAt: new Date().toISOString(), |
| updatedAt: new Date().toISOString(), |
| }; |
|
|
| const result = await api.features.create(currentProject.path, feature); |
| if (result.success) { |
| queryClient.invalidateQueries({ |
| queryKey: queryKeys.features.all(currentProject.path), |
| }); |
| toast.success( |
| `Created feature: ${featureData.title || featureData.description.slice(0, 50)}` |
| ); |
| setShowAddFeatureDialog(false); |
| setCreateFeatureIssue(null); |
| } else { |
| toast.error(result.error || 'Failed to create feature'); |
| } |
| } |
| } catch (err) { |
| logger.error('Create feature from issue error:', err); |
| toast.error(err instanceof Error ? err.message : 'Failed to create feature'); |
| } |
| }, |
| [currentProject?.path, currentBranch, queryClient, createFeatureIssue] |
| ); |
|
|
| const handleConvertToTask = useCallback( |
| async (issue: GitHubIssue, validation: IssueValidationResult) => { |
| if (!currentProject?.path) { |
| toast.error('No project selected'); |
| return; |
| } |
|
|
| try { |
| const api = getElectronAPI(); |
| if (api.features?.create) { |
| |
| const parts = [ |
| `**From GitHub Issue #${issue.number}**`, |
| '', |
| issue.body || 'No description provided.', |
| '', |
| '---', |
| '', |
| '**AI Validation Analysis:**', |
| validation.reasoning, |
| ]; |
| if (validation.suggestedFix) { |
| parts.push('', `**Suggested Approach:**`, validation.suggestedFix); |
| } |
| if (validation.relatedFiles?.length) { |
| parts.push( |
| '', |
| '**Related Files:**', |
| ...validation.relatedFiles.map((f) => `- \`${f}\``) |
| ); |
| } |
| const description = parts.join('\n'); |
|
|
| const feature = { |
| id: `issue-${issue.number}-${generateUUID()}`, |
| title: issue.title, |
| description, |
| category: 'From GitHub', |
| status: 'backlog' as const, |
| passes: false, |
| priority: getFeaturePriority(validation.estimatedComplexity), |
| model: resolveModelString('opus'), |
| thinkingLevel: 'none' as const, |
| branchName: currentBranch, |
| planningMode: 'skip' as const, |
| requirePlanApproval: false, |
| dependencies: [], |
| createdAt: new Date().toISOString(), |
| updatedAt: new Date().toISOString(), |
| }; |
|
|
| const result = await api.features.create(currentProject.path, feature); |
| if (result.success) { |
| |
| queryClient.invalidateQueries({ |
| queryKey: queryKeys.features.all(currentProject.path), |
| }); |
| toast.success(`Created task: ${issue.title}`); |
| } else { |
| toast.error(result.error || 'Failed to create task'); |
| } |
| } |
| } catch (err) { |
| logger.error('Convert to task error:', err); |
| toast.error(err instanceof Error ? err.message : 'Failed to create task'); |
| } |
| }, |
| [currentProject?.path, currentBranch, queryClient] |
| ); |
|
|
| if (loading) { |
| return <LoadingState />; |
| } |
|
|
| if (error) { |
| return <ErrorState error={error} title="Failed to Load Issues" onRetry={refresh} />; |
| } |
|
|
| const totalIssues = filteredOpenIssues.length + filteredClosedIssues.length; |
| const totalUnfilteredIssues = openIssues.length + closedIssues.length; |
| const isFilteredEmpty = |
| totalIssues === 0 && totalUnfilteredIssues > 0 && filterResult.hasActiveFilter; |
|
|
| return ( |
| <div className="flex-1 flex overflow-hidden"> |
| {/* Issues List - hidden on mobile when an issue is selected */} |
| <div |
| className={cn( |
| 'flex flex-col overflow-hidden border-r border-border', |
| selectedIssue ? 'w-80' : 'flex-1', |
| isMobile && selectedIssue && 'hidden' |
| )} |
| > |
| {/* Header */} |
| <IssuesListHeader |
| openCount={filteredOpenIssues.length} |
| closedCount={filteredClosedIssues.length} |
| totalOpenCount={openIssues.length} |
| totalClosedCount={closedIssues.length} |
| hasActiveFilter={filterResult.hasActiveFilter} |
| refreshing={refreshing} |
| onRefresh={refresh} |
| compact={!!selectedIssue} |
| filterProps={{ |
| stateFilter: filterState.stateFilter, |
| selectedLabels: filterState.selectedLabels, |
| availableLabels: filterResult.availableLabels, |
| onStateFilterChange: handleStateFilterChange, |
| onLabelsChange: handleLabelsChange, |
| }} |
| /> |
| |
| {/* Issues List */} |
| <div className="flex-1 overflow-auto"> |
| {totalIssues === 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"> |
| {isFilteredEmpty ? ( |
| <SearchX className="h-8 w-8 text-muted-foreground" /> |
| ) : ( |
| <CircleDot className="h-8 w-8 text-muted-foreground" /> |
| )} |
| </div> |
| <h2 className="text-base font-medium mb-2"> |
| {isFilteredEmpty ? 'No Matching Issues' : 'No Issues'} |
| </h2> |
| <p className="text-sm text-muted-foreground mb-4"> |
| {isFilteredEmpty |
| ? 'No issues match your current filters.' |
| : 'This repository has no issues yet.'} |
| </p> |
| {isFilteredEmpty && ( |
| <Button |
| variant="outline" |
| size="sm" |
| onClick={handleClearFilters} |
| className="text-xs" |
| > |
| Clear Filters |
| </Button> |
| )} |
| </div> |
| ) : ( |
| <div className="divide-y divide-border"> |
| {/* Open Issues */} |
| {filteredOpenIssues.map((issue) => ( |
| <IssueRow |
| key={issue.number} |
| issue={issue} |
| isSelected={selectedIssue?.number === issue.number} |
| onClick={() => setSelectedIssue(issue)} |
| onOpenExternal={() => handleOpenInGitHub(issue.url)} |
| formatDate={formatDate} |
| cachedValidation={cachedValidations.get(issue.number)} |
| isValidating={validatingIssues.has(issue.number)} |
| /> |
| ))} |
| |
| {/* Closed Issues Section */} |
| {filteredClosedIssues.length > 0 && ( |
| <> |
| <div className="px-4 py-2 bg-muted/30 text-xs font-medium text-muted-foreground"> |
| Closed Issues ({filteredClosedIssues.length}) |
| </div> |
| {filteredClosedIssues.map((issue) => ( |
| <IssueRow |
| key={issue.number} |
| issue={issue} |
| isSelected={selectedIssue?.number === issue.number} |
| onClick={() => setSelectedIssue(issue)} |
| onOpenExternal={() => handleOpenInGitHub(issue.url)} |
| formatDate={formatDate} |
| cachedValidation={cachedValidations.get(issue.number)} |
| isValidating={validatingIssues.has(issue.number)} |
| /> |
| ))} |
| </> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
|
|
| {} |
| {selectedIssue && ( |
| <IssueDetailPanel |
| issue={selectedIssue} |
| validatingIssues={validatingIssues} |
| cachedValidations={cachedValidations} |
| onValidateIssue={handleValidateIssue} |
| onViewCachedValidation={handleViewCachedValidation} |
| onOpenInGitHub={handleOpenInGitHub} |
| onClose={() => setSelectedIssue(null)} |
| onShowRevalidateConfirm={(options) => { |
| setPendingRevalidateOptions(options); |
| setShowRevalidateConfirm(true); |
| }} |
| onCreateFeature={handleCreateFeature} |
| formatDate={formatDate} |
| modelOverride={validationModelOverride} |
| isMobile={isMobile} |
| /> |
| )} |
|
|
| {} |
| <ValidationDialog |
| open={showValidationDialog} |
| onOpenChange={setShowValidationDialog} |
| issue={selectedIssue} |
| validationResult={validationResult} |
| onConvertToTask={handleConvertToTask} |
| /> |
|
|
| {} |
| <AddFeatureDialog |
| open={showAddFeatureDialog} |
| onOpenChange={(open) => { |
| setShowAddFeatureDialog(open); |
| if (!open) { |
| setCreateFeatureIssue(null); |
| } |
| }} |
| onAdd={handleAddFeatureFromIssue} |
| categorySuggestions={['From GitHub']} |
| branchSuggestions={[]} |
| defaultSkipTests={defaultSkipTests} |
| defaultBranch={currentBranch} |
| currentBranch={currentBranch || undefined} |
| isMaximized={false} |
| projectPath={currentProject?.path} |
| prefilledTitle={createFeatureIssue?.title} |
| prefilledDescription={prefilledDescription} |
| prefilledCategory="From GitHub" |
| /> |
|
|
| {} |
| <ConfirmDialog |
| open={showRevalidateConfirm} |
| onOpenChange={(open) => { |
| setShowRevalidateConfirm(open); |
| if (!open) { |
| setPendingRevalidateOptions(null); |
| } |
| }} |
| title="Re-validate Issue" |
| description={`Are you sure you want to re-validate issue #${selectedIssue?.number}? This will run a new AI analysis and replace the existing validation result.`} |
| icon={RefreshCw} |
| iconClassName="text-primary" |
| confirmText="Re-validate" |
| onConfirm={() => { |
| if (selectedIssue && pendingRevalidateOptions) { |
| logger.info('Revalidating with options:', { |
| commentsCount: pendingRevalidateOptions.comments?.length ?? 0, |
| linkedPRsCount: pendingRevalidateOptions.linkedPRs?.length ?? 0, |
| }); |
| handleValidateIssue(selectedIssue, { |
| ...pendingRevalidateOptions, |
| forceRevalidate: true, |
| }); |
| } |
| }} |
| /> |
| </div> |
| ); |
| } |
|
|