// ======================================== // useIssues Hook // ======================================== // TanStack Query hooks for issues with queue management import { useQuery, useMutation, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; import { fetchIssues, fetchIssueHistory, fetchIssueQueue, createIssue, updateIssue, deleteIssue, activateQueue, deactivateQueue, deleteQueue as deleteQueueApi, mergeQueues as mergeQueuesApi, fetchDiscoveries, fetchDiscoveryFindings, type Issue, type IssueQueue, type IssuesResponse, type DiscoverySession, type Finding, } from '../lib/api'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { workspaceQueryKeys } from '@/lib/queryKeys'; import { useState, useMemo } from 'react'; // Query key factory export const issuesKeys = { all: ['issues'] as const, lists: () => [...issuesKeys.all, 'list'] as const, list: (filters?: IssuesFilter) => [...issuesKeys.lists(), filters] as const, history: () => [...issuesKeys.all, 'history'] as const, queue: () => [...issuesKeys.all, 'queue'] as const, details: () => [...issuesKeys.all, 'detail'] as const, detail: (id: string) => [...issuesKeys.details(), id] as const, }; // Default stale time: 30 seconds const STALE_TIME = 30 * 1000; export interface IssuesFilter { status?: Issue['status'][]; priority?: Issue['priority'][]; search?: string; includeHistory?: boolean; } export interface UseIssuesOptions { filter?: IssuesFilter; projectPath?: string; staleTime?: number; enabled?: boolean; refetchInterval?: number; } export interface UseIssuesReturn { issues: Issue[]; historyIssues: Issue[]; allIssues: Issue[]; issuesByStatus: Record; issuesByPriority: Record; openCount: number; criticalCount: number; isLoading: boolean; isFetching: boolean; error: Error | null; refetch: () => Promise; invalidate: () => Promise; } /** * Hook for fetching and filtering issues */ export function useIssues(options: UseIssuesOptions = {}): UseIssuesReturn { const { filter, staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options; const queryClient = useQueryClient(); const projectPath = useWorkflowStore(selectProjectPath); // Only enable query when projectPath is available const queryEnabled = enabled && !!projectPath; const issuesQuery = useQuery({ queryKey: workspaceQueryKeys.issuesList(projectPath), queryFn: () => fetchIssues(projectPath), staleTime, enabled: queryEnabled, refetchInterval: refetchInterval > 0 ? refetchInterval : false, retry: 2, }); const historyQuery = useQuery({ queryKey: workspaceQueryKeys.issuesHistory(projectPath), queryFn: () => fetchIssueHistory(projectPath), staleTime, enabled: queryEnabled && (filter?.includeHistory ?? false), retry: 2, }); const allIssues = issuesQuery.data?.issues ?? []; const historyIssues = historyQuery.data?.issues ?? []; // Apply filters const filteredIssues = (() => { let issues = [...allIssues]; if (filter?.includeHistory) { issues = [...issues, ...historyIssues]; } if (filter?.status && filter.status.length > 0) { issues = issues.filter((i) => filter.status!.includes(i.status)); } if (filter?.priority && filter.priority.length > 0) { issues = issues.filter((i) => filter.priority!.includes(i.priority)); } if (filter?.search) { const searchLower = filter.search.toLowerCase(); issues = issues.filter( (i) => i.id.toLowerCase().includes(searchLower) || i.title.toLowerCase().includes(searchLower) || i.context?.toLowerCase().includes(searchLower) ); } return issues; })(); // Group by status const issuesByStatus: Record = { open: [], in_progress: [], resolved: [], closed: [], completed: [], }; for (const issue of allIssues) { // Defensive check: only push if the status key exists if (issue.status in issuesByStatus) { issuesByStatus[issue.status].push(issue); } } // Group by priority const issuesByPriority: Record = { low: [], medium: [], high: [], critical: [], }; for (const issue of allIssues) { // Defensive check: only push if the priority key exists if (issue.priority in issuesByPriority) { issuesByPriority[issue.priority].push(issue); } } const refetch = async () => { await Promise.all([issuesQuery.refetch(), historyQuery.refetch()]); }; const invalidate = async () => { if (projectPath) { await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issues(projectPath) }); } }; return { issues: filteredIssues, historyIssues, allIssues, issuesByStatus, issuesByPriority, openCount: issuesByStatus.open.length + issuesByStatus.in_progress.length, criticalCount: issuesByPriority.critical.length, isLoading: issuesQuery.isLoading, isFetching: issuesQuery.isFetching || historyQuery.isFetching, error: issuesQuery.error || historyQuery.error, refetch, invalidate, }; } /** * Hook for fetching issue queue */ export function useIssueQueue(): UseQueryResult { const projectPath = useWorkflowStore(selectProjectPath); return useQuery({ queryKey: projectPath ? workspaceQueryKeys.issueQueue(projectPath) : ['issueQueue', 'no-project'], queryFn: () => fetchIssueQueue(projectPath), staleTime: STALE_TIME, enabled: !!projectPath, retry: 2, }); } // ========== Mutations ========== export interface UseCreateIssueReturn { createIssue: (input: { title: string; context?: string; priority?: Issue['priority'] }) => Promise; isCreating: boolean; error: Error | null; } export function useCreateIssue(): UseCreateIssueReturn { const queryClient = useQueryClient(); const projectPath = useWorkflowStore(selectProjectPath); const mutation = useMutation({ mutationFn: createIssue, onSuccess: () => { // Invalidate issues cache to trigger refetch queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.issues(projectPath) : ['issues'] }); }, }); return { createIssue: mutation.mutateAsync, isCreating: mutation.isPending, error: mutation.error, }; } export interface UseUpdateIssueReturn { updateIssue: (issueId: string, input: Partial) => Promise; isUpdating: boolean; error: Error | null; } export function useUpdateIssue(): UseUpdateIssueReturn { const queryClient = useQueryClient(); const projectPath = useWorkflowStore(selectProjectPath); const mutation = useMutation({ mutationFn: ({ issueId, input }: { issueId: string; input: Partial }) => updateIssue(issueId, input), onSuccess: () => { // Invalidate issues cache to trigger refetch queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.issues(projectPath) : ['issues'] }); }, }); return { updateIssue: (issueId, input) => mutation.mutateAsync({ issueId, input }), isUpdating: mutation.isPending, error: mutation.error, }; } export interface UseDeleteIssueReturn { deleteIssue: (issueId: string) => Promise; isDeleting: boolean; error: Error | null; } export function useDeleteIssue(): UseDeleteIssueReturn { const queryClient = useQueryClient(); const projectPath = useWorkflowStore(selectProjectPath); const mutation = useMutation({ mutationFn: deleteIssue, onSuccess: () => { // Invalidate to ensure sync with server queryClient.invalidateQueries({ queryKey: projectPath ? workspaceQueryKeys.issues(projectPath) : ['issues'] }); }, }); return { deleteIssue: mutation.mutateAsync, isDeleting: mutation.isPending, error: mutation.error, }; } /** * Combined hook for all issue mutations */ export function useIssueMutations() { const create = useCreateIssue(); const update = useUpdateIssue(); const remove = useDeleteIssue(); return { createIssue: create.createIssue, updateIssue: update.updateIssue, deleteIssue: remove.deleteIssue, isCreating: create.isCreating, isUpdating: update.isUpdating, isDeleting: remove.isDeleting, isMutating: create.isCreating || update.isUpdating || remove.isDeleting, }; } // ========== Queue Mutations ========== export interface UseQueueMutationsReturn { activateQueue: (queueId: string) => Promise; deactivateQueue: () => Promise; deleteQueue: (queueId: string) => Promise; mergeQueues: (sourceId: string, targetId: string) => Promise; isActivating: boolean; isDeactivating: boolean; isDeleting: boolean; isMerging: boolean; isMutating: boolean; } export function useQueueMutations(): UseQueueMutationsReturn { const queryClient = useQueryClient(); const projectPath = useWorkflowStore(selectProjectPath); const activateMutation = useMutation({ mutationFn: (queueId: string) => activateQueue(queueId, projectPath), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); }, }); const deactivateMutation = useMutation({ mutationFn: () => deactivateQueue(projectPath), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); }, }); const deleteMutation = useMutation({ mutationFn: (queueId: string) => deleteQueueApi(queueId, projectPath), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); }, }); const mergeMutation = useMutation({ mutationFn: ({ sourceId, targetId }: { sourceId: string; targetId: string }) => mergeQueuesApi(sourceId, targetId, projectPath), onSuccess: () => { queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.issueQueue(projectPath) }); }, }); return { activateQueue: activateMutation.mutateAsync, deactivateQueue: deactivateMutation.mutateAsync, deleteQueue: deleteMutation.mutateAsync, mergeQueues: (sourceId, targetId) => mergeMutation.mutateAsync({ sourceId, targetId }), isActivating: activateMutation.isPending, isDeactivating: deactivateMutation.isPending, isDeleting: deleteMutation.isPending, isMerging: mergeMutation.isPending, isMutating: activateMutation.isPending || deactivateMutation.isPending || deleteMutation.isPending || mergeMutation.isPending, }; } // ========== Discovery Hook ========== export interface FindingFilters { severity?: 'critical' | 'high' | 'medium' | 'low'; type?: string; search?: string; } export interface UseIssueDiscoveryReturn { sessions: DiscoverySession[]; activeSession: DiscoverySession | null; findings: Finding[]; filteredFindings: Finding[]; isLoadingSessions: boolean; isLoadingFindings: boolean; error: Error | null; filters: FindingFilters; setFilters: (filters: FindingFilters) => void; selectSession: (sessionId: string) => void; refetchSessions: () => void; exportFindings: () => void; } export function useIssueDiscovery(options?: { refetchInterval?: number }): UseIssueDiscoveryReturn { const { refetchInterval = 0 } = options ?? {}; const queryClient = useQueryClient(); const projectPath = useWorkflowStore(selectProjectPath); const [activeSessionId, setActiveSessionId] = useState(null); const [filters, setFilters] = useState({}); const sessionsQuery = useQuery({ queryKey: workspaceQueryKeys.discoveries(projectPath), queryFn: () => fetchDiscoveries(projectPath), staleTime: STALE_TIME, enabled: !!projectPath, refetchInterval: refetchInterval > 0 ? refetchInterval : false, retry: 2, }); const findingsQuery = useQuery({ queryKey: activeSessionId ? ['discoveryFindings', activeSessionId, projectPath] : ['discoveryFindings', 'no-session'], queryFn: () => activeSessionId ? fetchDiscoveryFindings(activeSessionId, projectPath) : [], staleTime: STALE_TIME, enabled: !!activeSessionId && !!projectPath, retry: 2, }); const activeSession = useMemo( () => sessionsQuery.data?.find(s => s.id === activeSessionId) ?? null, [sessionsQuery.data, activeSessionId] ); const filteredFindings = useMemo(() => { let findings = findingsQuery.data ?? []; if (filters.severity) { findings = findings.filter(f => f.severity === filters.severity); } if (filters.type) { findings = findings.filter(f => f.type === filters.type); } if (filters.search) { const searchLower = filters.search.toLowerCase(); findings = findings.filter(f => f.title.toLowerCase().includes(searchLower) || f.description.toLowerCase().includes(searchLower) ); } return findings; }, [findingsQuery.data, filters]); const selectSession = (sessionId: string) => { setActiveSessionId(sessionId); }; const exportFindings = () => { if (!activeSessionId || !findingsQuery.data) return; const data = { session: activeSession, findings: findingsQuery.data, exported_at: new Date().toISOString(), }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `discovery-${activeSessionId}.json`; a.click(); URL.revokeObjectURL(url); }; return { sessions: sessionsQuery.data ?? [], activeSession, findings: findingsQuery.data ?? [], filteredFindings, isLoadingSessions: sessionsQuery.isLoading, isLoadingFindings: findingsQuery.isLoading, error: sessionsQuery.error || findingsQuery.error, filters, setFilters, selectSession, refetchSessions: () => { sessionsQuery.refetch(); }, exportFindings, }; }