diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index e8e5536b8..7bbd03431 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { createLogger } from '@automaker/utils/logger'; import { CircleDot, RefreshCw } from 'lucide-react'; import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron'; @@ -11,7 +11,7 @@ import { cn, pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks'; import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components'; -import { ValidationDialog } from './github-issues-view/dialogs'; +import { ValidationDialog, ImportIssuesDialog } from './github-issues-view/dialogs'; import { formatDate, getFeaturePriority } from './github-issues-view/utils'; import { useModelOverride } from '@/components/shared'; import type { ValidateIssueOptions } from './github-issues-view/types'; @@ -23,11 +23,19 @@ export function GitHubIssuesView() { const [validationResult, setValidationResult] = useState(null); const [showValidationDialog, setShowValidationDialog] = useState(false); const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false); + const [showImportDialog, setShowImportDialog] = useState(false); const [pendingRevalidateOptions, setPendingRevalidateOptions] = useState(null); - const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } = - useAppStore(); + const { + currentProject, + defaultAIProfileId, + aiProfiles, + getCurrentWorktree, + worktreesByProject, + githubAutoValidate, + setGithubAutoValidate, + } = useAppStore(); // Model override for validation const validationModelOverride = useModelOverride({ phase: 'validationModel' }); @@ -45,6 +53,54 @@ export function GitHubIssuesView() { onShowValidationDialogChange: setShowValidationDialog, }); + // Track which issues we've already triggered auto-validate for (to avoid duplicates) + const autoValidatedIssuesRef = useRef>(new Set()); + + // Auto-validate issues when enabled and issues are loaded + useEffect(() => { + if (!githubAutoValidate || loading || openIssues.length === 0) return; + + // Find issues that need validation (not cached and not currently validating) + const issuesToValidate = openIssues.filter((issue) => { + // Skip if already auto-validated in this session + if (autoValidatedIssuesRef.current.has(issue.number)) return false; + // Skip if already has cached validation + if (cachedValidations.has(issue.number)) return false; + // Skip if currently validating + if (validatingIssues.has(issue.number)) return false; + return true; + }); + + // Validate up to 3 issues at a time to avoid overwhelming the system + const batchSize = 3; + const batch = issuesToValidate.slice(0, batchSize); + + for (const issue of batch) { + autoValidatedIssuesRef.current.add(issue.number); + handleValidateIssue(issue); + } + + if (batch.length > 0) { + logger.info( + `Auto-validating ${batch.length} issues (${issuesToValidate.length - batch.length} remaining)` + ); + } + }, [ + githubAutoValidate, + loading, + openIssues, + cachedValidations, + validatingIssues, + handleValidateIssue, + ]); + + // Reset auto-validated tracking when auto-validate is toggled off + useEffect(() => { + if (!githubAutoValidate) { + autoValidatedIssuesRef.current.clear(); + } + }, [githubAutoValidate]); + // Get default AI profile for task creation const defaultProfile = useMemo(() => { if (!defaultAIProfileId) return null; @@ -132,6 +188,42 @@ export function GitHubIssuesView() { [currentProject?.path, defaultProfile, currentBranch] ); + // Handle bulk import of issues as tasks + const handleImportIssues = useCallback( + async (issues: GitHubIssue[]) => { + if (!currentProject?.path) { + toast.error('No project selected'); + return; + } + + let successCount = 0; + let errorCount = 0; + + for (const issue of issues) { + const validation = cachedValidations.get(issue.number); + if (!validation?.result) { + errorCount++; + continue; + } + + try { + await handleConvertToTask(issue, validation.result); + successCount++; + } catch { + errorCount++; + } + } + + if (successCount > 0) { + toast.success(`Imported ${successCount} issue${successCount !== 1 ? 's' : ''} as tasks`); + } + if (errorCount > 0) { + toast.error(`Failed to import ${errorCount} issue${errorCount !== 1 ? 's' : ''}`); + } + }, + [currentProject?.path, cachedValidations, handleConvertToTask] + ); + if (loading) { return ; } @@ -157,6 +249,9 @@ export function GitHubIssuesView() { closedCount={closedIssues.length} refreshing={refreshing} onRefresh={refresh} + autoValidate={githubAutoValidate} + onAutoValidateChange={setGithubAutoValidate} + onImportClick={() => setShowImportDialog(true)} /> {/* Issues List */} @@ -265,6 +360,16 @@ export function GitHubIssuesView() { } }} /> + + {/* Import Issues Dialog */} + ); } diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx index 5529b30c6..8a445cb20 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx @@ -1,5 +1,7 @@ -import { CircleDot, RefreshCw } from 'lucide-react'; +import { CircleDot, RefreshCw, Zap, Import } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; interface IssuesListHeaderProps { @@ -7,6 +9,9 @@ interface IssuesListHeaderProps { closedCount: number; refreshing: boolean; onRefresh: () => void; + autoValidate: boolean; + onAutoValidateChange: (enabled: boolean) => void; + onImportClick: () => void; } export function IssuesListHeader({ @@ -14,6 +19,9 @@ export function IssuesListHeader({ closedCount, refreshing, onRefresh, + autoValidate, + onAutoValidateChange, + onImportClick, }: IssuesListHeaderProps) { const totalIssues = openCount + closedCount; @@ -30,9 +38,55 @@ export function IssuesListHeader({

- +
+ {/* Auto-validate toggle */} + + + +
+ + +
+
+ +

Auto-validate: {autoValidate ? 'ON' : 'OFF'}

+

+ {autoValidate + ? 'Issues are validated automatically when loaded' + : 'Click validate button for each issue'} +

+
+
+
+ + {/* Import button */} + + + + + + +

Import issues as tasks

+
+
+
+ + {/* Refresh button */} + +
); } diff --git a/apps/ui/src/components/views/github-issues-view/dialogs/import-issues-dialog.tsx b/apps/ui/src/components/views/github-issues-view/dialogs/import-issues-dialog.tsx new file mode 100644 index 000000000..a334a5908 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/dialogs/import-issues-dialog.tsx @@ -0,0 +1,281 @@ +import { useState, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { CheckCircle2, XCircle, AlertCircle, Import, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { GitHubIssue, StoredValidation, IssueValidationVerdict } from '@/lib/electron'; + +interface ImportIssuesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + issues: GitHubIssue[]; + cachedValidations: Map; + validatingIssues: Set; + onImport: (issues: GitHubIssue[]) => Promise; +} + +const verdictConfig: Record< + IssueValidationVerdict, + { label: string; color: string; bgColor: string; icon: typeof CheckCircle2 } +> = { + valid: { + label: 'Valid', + color: 'text-green-500', + bgColor: 'bg-green-500/10', + icon: CheckCircle2, + }, + invalid: { + label: 'Invalid', + color: 'text-red-500', + bgColor: 'bg-red-500/10', + icon: XCircle, + }, + needs_clarification: { + label: 'Needs Clarification', + color: 'text-yellow-500', + bgColor: 'bg-yellow-500/10', + icon: AlertCircle, + }, +}; + +export function ImportIssuesDialog({ + open, + onOpenChange, + issues, + cachedValidations, + validatingIssues, + onImport, +}: ImportIssuesDialogProps) { + const [selectedIssues, setSelectedIssues] = useState>(new Set()); + const [importing, setImporting] = useState(false); + + // Filter issues that can be imported (have valid validation) + const importableIssues = useMemo(() => { + return issues.filter((issue) => { + const validation = cachedValidations.get(issue.number); + return validation?.result.verdict === 'valid'; + }); + }, [issues, cachedValidations]); + + // Count by status + const statusCounts = useMemo(() => { + let valid = 0; + let invalid = 0; + let needsClarification = 0; + let notValidated = 0; + + for (const issue of issues) { + const validation = cachedValidations.get(issue.number); + if (!validation) { + notValidated++; + } else { + switch (validation.result.verdict) { + case 'valid': + valid++; + break; + case 'invalid': + invalid++; + break; + case 'needs_clarification': + needsClarification++; + break; + } + } + } + + return { valid, invalid, needsClarification, notValidated }; + }, [issues, cachedValidations]); + + const handleSelectAll = () => { + if (selectedIssues.size === importableIssues.length) { + setSelectedIssues(new Set()); + } else { + setSelectedIssues(new Set(importableIssues.map((i) => i.number))); + } + }; + + const handleToggleIssue = (issueNumber: number) => { + const newSelected = new Set(selectedIssues); + if (newSelected.has(issueNumber)) { + newSelected.delete(issueNumber); + } else { + newSelected.add(issueNumber); + } + setSelectedIssues(newSelected); + }; + + const handleImport = async () => { + const issuesToImport = issues.filter((i) => selectedIssues.has(i.number)); + if (issuesToImport.length === 0) return; + + setImporting(true); + try { + await onImport(issuesToImport); + setSelectedIssues(new Set()); + onOpenChange(false); + } finally { + setImporting(false); + } + }; + + // Reset selection when dialog opens + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + setSelectedIssues(new Set()); + } + onOpenChange(newOpen); + }; + + return ( + + + + + + Import Issues as Tasks + + + Select validated issues to import as tasks into your backlog. + + + + {/* Status Summary */} +
+ + {statusCounts.valid} valid + + + {statusCounts.needsClarification} needs clarification + + + {statusCounts.invalid} invalid + + + {statusCounts.notValidated} not validated + +
+ + {/* Issues List */} +
+ {issues.length === 0 ? ( +
+ +

No open issues found.

+
+ ) : ( +
+ {issues.map((issue) => { + const validation = cachedValidations.get(issue.number); + const isValidating = validatingIssues.has(issue.number); + const isImportable = validation?.result.verdict === 'valid'; + const isSelected = selectedIssues.has(issue.number); + + return ( +
isImportable && handleToggleIssue(issue.number)} + > + isImportable && handleToggleIssue(issue.number)} + /> +
+
+ + #{issue.number} + + {issue.title} +
+ {issue.labels.length > 0 && ( +
+ {issue.labels.slice(0, 3).map((label) => ( + + {label.name} + + ))} + {issue.labels.length > 3 && ( + + +{issue.labels.length - 3} + + )} +
+ )} +
+
+ {isValidating ? ( + + ) : validation ? ( + (() => { + const config = verdictConfig[validation.result.verdict]; + const Icon = config.icon; + return ( +
+ + {config.label} +
+ ); + })() + ) : ( + Not validated + )} +
+
+ ); + })} +
+ )} +
+ + +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/dialogs/index.ts b/apps/ui/src/components/views/github-issues-view/dialogs/index.ts index 886b09b23..4c2bf73eb 100644 --- a/apps/ui/src/components/views/github-issues-view/dialogs/index.ts +++ b/apps/ui/src/components/views/github-issues-view/dialogs/index.ts @@ -1 +1,2 @@ export { ValidationDialog } from './validation-dialog'; +export { ImportIssuesDialog } from './import-issues-dialog'; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 78d6e65cf..34604123d 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -557,6 +557,9 @@ export interface AppState { // Validation Model Settings validationModel: ModelAlias; // Model used for GitHub issue validation (default: opus) + // GitHub Auto-Validate Settings + githubAutoValidate: boolean; // When true, automatically validate issues when viewing GitHub Issues tab + // Phase Model Settings - per-phase AI model configuration phaseModels: PhaseModelConfig; favoriteModels: string[]; @@ -924,6 +927,9 @@ export interface AppActions { // Validation Model actions setValidationModel: (model: ModelAlias) => void; + // GitHub Auto-Validate actions + setGithubAutoValidate: (enabled: boolean) => void; + // Phase Model actions setPhaseModel: (phase: PhaseModelKey, entry: PhaseModelEntry) => Promise; setPhaseModels: (models: Partial) => Promise; @@ -1180,6 +1186,7 @@ const initialState: AppState = { muteDoneSound: false, // Default to sound enabled (not muted) enhancementModel: 'sonnet', // Default to sonnet for feature enhancement validationModel: 'opus', // Default to opus for GitHub issue validation + githubAutoValidate: false, // Default to disabled (manual validation only) phaseModels: DEFAULT_PHASE_MODELS, // Phase-specific model configuration favoriteModels: [], enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default @@ -1843,6 +1850,9 @@ export const useAppStore = create()((set, get) => ({ // Validation Model actions setValidationModel: (model) => set({ validationModel: model }), + // GitHub Auto-Validate actions + setGithubAutoValidate: (enabled) => set({ githubAutoValidate: enabled }), + // Phase Model actions setPhaseModel: async (phase, entry) => { set((state) => ({