Files
automaker/apps/ui/src/components/views/github-issues-view.tsx

339 lines
13 KiB
TypeScript

// @ts-nocheck
import { useState, useCallback, useMemo } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { CircleDot, RefreshCw, SearchX } from 'lucide-react';
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 { toast } from 'sonner';
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 { formatDate, getFeaturePriority } from './github-issues-view/utils';
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);
// Filter state
const [filterState, setFilterState] = useState<IssuesFilterState>(DEFAULT_ISSUES_FILTER_STATE);
const { currentProject, getCurrentWorktree, worktreesByProject } = useAppStore();
// Model override for validation
const validationModelOverride = useModelOverride({ phase: 'validationModel' });
// Extract model string for API calls (backward compatibility)
const validationModelString = validationModelOverride.effectiveModel;
const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues();
const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } =
useIssueValidation({
selectedIssue,
showValidationDialog,
onValidationResultChange: setValidationResult,
onShowValidationDialogChange: setShowValidationDialog,
});
// Combine all issues for filtering
const allIssues = useMemo(() => [...openIssues, ...closedIssues], [openIssues, closedIssues]);
// Apply filter to issues - now returns matched issues directly for better performance
const filterResult = useIssuesFilter(allIssues, filterState, cachedValidations);
// Separate filtered issues by state - this is O(n) but now only done once
// since filterResult.matchedIssues already contains the filtered issues
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]);
// Filter state change handlers
const handleStateFilterChange = useCallback((stateFilter: IssuesStateFilter) => {
setFilterState((prev) => ({ ...prev, stateFilter }));
}, []);
const handleLabelsChange = useCallback((selectedLabels: string[]) => {
setFilterState((prev) => ({ ...prev, selectedLabels }));
}, []);
// Clear all filters to default state
const handleClearFilters = useCallback(() => {
setFilterState(DEFAULT_ISSUES_FILTER_STATE);
}, []);
// Get current branch from selected worktree
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 handleConvertToTask = useCallback(
async (issue: GitHubIssue, validation: IssueValidationResult) => {
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
try {
const api = getElectronAPI();
if (api.features?.create) {
// Build description from issue body + validation info
const description = [
`**From GitHub Issue #${issue.number}**`,
'',
issue.body || 'No description provided.',
'',
'---',
'',
'**AI Validation Analysis:**',
validation.reasoning,
validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '',
validation.relatedFiles?.length
? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}`
: '',
]
.filter(Boolean)
.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: 'opus',
thinkingLevel: 'none' as const,
branchName: currentBranch,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const result = await api.features.create(currentProject.path, feature);
if (result.success) {
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]
);
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 */}
<div
className={cn(
'flex flex-col overflow-hidden border-r border-border',
selectedIssue ? 'w-80' : 'flex-1'
)}
>
{/* 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>
{/* Issue Detail Panel */}
{selectedIssue && (
<IssueDetailPanel
issue={selectedIssue}
validatingIssues={validatingIssues}
cachedValidations={cachedValidations}
onValidateIssue={handleValidateIssue}
onViewCachedValidation={handleViewCachedValidation}
onOpenInGitHub={handleOpenInGitHub}
onClose={() => setSelectedIssue(null)}
onShowRevalidateConfirm={(options) => {
setPendingRevalidateOptions(options);
setShowRevalidateConfirm(true);
}}
formatDate={formatDate}
modelOverride={validationModelOverride}
/>
)}
{/* Validation Dialog */}
<ValidationDialog
open={showValidationDialog}
onOpenChange={setShowValidationDialog}
issue={selectedIssue}
validationResult={validationResult}
onConvertToTask={handleConvertToTask}
/>
{/* Revalidate Confirmation Dialog */}
<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>
);
}