| |
| import { useState, useEffect, useCallback, useRef } from 'react'; |
| import { createLogger } from '@automaker/utils/logger'; |
| import { |
| getElectronAPI, |
| GitHubIssue, |
| GitHubComment, |
| IssueValidationResult, |
| IssueValidationEvent, |
| StoredValidation, |
| } from '@/lib/electron'; |
| import type { LinkedPRInfo, PhaseModelEntry, ModelId } from '@automaker/types'; |
| import { useAppStore } from '@/store/app-store'; |
| import { toast } from 'sonner'; |
| import { isValidationStale } from '../utils'; |
| import { useValidateIssue, useMarkValidationViewed } from '@/hooks/mutations'; |
|
|
| const logger = createLogger('IssueValidation'); |
|
|
| interface UseIssueValidationOptions { |
| selectedIssue: GitHubIssue | null; |
| showValidationDialog: boolean; |
| onValidationResultChange: (result: IssueValidationResult | null) => void; |
| onShowValidationDialogChange: (show: boolean) => void; |
| } |
|
|
| export function useIssueValidation({ |
| selectedIssue, |
| showValidationDialog, |
| onValidationResultChange, |
| onShowValidationDialogChange, |
| }: UseIssueValidationOptions) { |
| const { currentProject, phaseModels, muteDoneSound } = useAppStore(); |
| const [validatingIssues, setValidatingIssues] = useState<Set<number>>(new Set()); |
| const [cachedValidations, setCachedValidations] = useState<Map<number, StoredValidation>>( |
| new Map() |
| ); |
| const audioRef = useRef<HTMLAudioElement | null>(null); |
|
|
| |
| const validateIssueMutation = useValidateIssue(currentProject?.path ?? ''); |
| const markViewedMutation = useMarkValidationViewed(currentProject?.path ?? ''); |
| |
| const selectedIssueRef = useRef<GitHubIssue | null>(null); |
| const showValidationDialogRef = useRef(false); |
|
|
| |
| useEffect(() => { |
| selectedIssueRef.current = selectedIssue; |
| }, [selectedIssue]); |
|
|
| useEffect(() => { |
| showValidationDialogRef.current = showValidationDialog; |
| }, [showValidationDialog]); |
|
|
| |
| useEffect(() => { |
| let isMounted = true; |
|
|
| const loadCachedValidations = async () => { |
| if (!currentProject?.path) return; |
|
|
| try { |
| const api = getElectronAPI(); |
| if (api.github?.getValidations) { |
| const result = await api.github.getValidations(currentProject.path); |
| if (isMounted && result.success && result.validations) { |
| const map = new Map<number, StoredValidation>(); |
| for (const v of result.validations) { |
| map.set(v.issueNumber, v); |
| } |
| setCachedValidations(map); |
| } |
| } |
| } catch (err) { |
| if (isMounted) { |
| logger.error('Failed to load cached validations:', err); |
| } |
| } |
| }; |
|
|
| loadCachedValidations(); |
|
|
| return () => { |
| isMounted = false; |
| }; |
| }, [currentProject?.path]); |
|
|
| |
| useEffect(() => { |
| let isMounted = true; |
|
|
| const loadRunningValidations = async () => { |
| if (!currentProject?.path) return; |
|
|
| try { |
| const api = getElectronAPI(); |
| if (api.github?.getValidationStatus) { |
| const result = await api.github.getValidationStatus(currentProject.path); |
| if (isMounted && result.success && result.runningIssues) { |
| setValidatingIssues(new Set(result.runningIssues)); |
| } |
| } |
| } catch (err) { |
| if (isMounted) { |
| logger.error('Failed to load running validations:', err); |
| } |
| } |
| }; |
|
|
| loadRunningValidations(); |
|
|
| return () => { |
| isMounted = false; |
| }; |
| }, [currentProject?.path]); |
|
|
| |
| useEffect(() => { |
| const api = getElectronAPI(); |
| if (!api.github?.onValidationEvent) return; |
|
|
| const handleValidationEvent = (event: IssueValidationEvent) => { |
| |
| if (event.projectPath !== currentProject?.path) return; |
|
|
| switch (event.type) { |
| case 'issue_validation_start': |
| setValidatingIssues((prev) => new Set([...prev, event.issueNumber])); |
| break; |
|
|
| case 'issue_validation_complete': |
| setValidatingIssues((prev) => { |
| const next = new Set(prev); |
| next.delete(event.issueNumber); |
| return next; |
| }); |
|
|
| |
| setCachedValidations((prev) => { |
| const next = new Map(prev); |
| next.set(event.issueNumber, { |
| issueNumber: event.issueNumber, |
| issueTitle: event.issueTitle, |
| validatedAt: new Date().toISOString(), |
| model: event.model, |
| result: event.result, |
| }); |
| return next; |
| }); |
|
|
| |
| toast.success(`Issue #${event.issueNumber} validated: ${event.result.verdict}`, { |
| description: |
| event.result.verdict === 'valid' |
| ? 'Issue is ready to be converted to a task' |
| : event.result.verdict === 'invalid' |
| ? 'Issue may have problems' |
| : 'Issue needs clarification', |
| }); |
|
|
| |
| if (!muteDoneSound) { |
| try { |
| if (!audioRef.current) { |
| audioRef.current = new Audio('/sounds/ding.mp3'); |
| } |
| audioRef.current.play().catch(() => { |
| |
| }); |
| } catch { |
| |
| } |
| } |
|
|
| |
| if ( |
| selectedIssueRef.current?.number === event.issueNumber && |
| showValidationDialogRef.current |
| ) { |
| onValidationResultChange(event.result); |
| } |
| break; |
|
|
| case 'issue_validation_error': |
| setValidatingIssues((prev) => { |
| const next = new Set(prev); |
| next.delete(event.issueNumber); |
| return next; |
| }); |
| toast.error(`Validation failed for issue #${event.issueNumber}`, { |
| description: event.error, |
| }); |
| if ( |
| selectedIssueRef.current?.number === event.issueNumber && |
| showValidationDialogRef.current |
| ) { |
| onShowValidationDialogChange(false); |
| } |
| break; |
| } |
| }; |
|
|
| const unsubscribe = api.github.onValidationEvent(handleValidationEvent); |
| return () => unsubscribe(); |
| }, [currentProject?.path, muteDoneSound, onValidationResultChange, onShowValidationDialogChange]); |
|
|
| |
| useEffect(() => { |
| return () => { |
| if (audioRef.current) { |
| audioRef.current.pause(); |
| audioRef.current = null; |
| } |
| }; |
| }, []); |
|
|
| const handleValidateIssue = useCallback( |
| async ( |
| issue: GitHubIssue, |
| options: { |
| forceRevalidate?: boolean; |
| model?: ModelId | PhaseModelEntry; |
| modelEntry?: PhaseModelEntry; |
| comments?: GitHubComment[]; |
| linkedPRs?: LinkedPRInfo[]; |
| } = {} |
| ) => { |
| const { forceRevalidate = false, model, modelEntry, comments, linkedPRs } = options; |
|
|
| if (!currentProject?.path) { |
| toast.error('No project selected'); |
| return; |
| } |
|
|
| |
| if (validatingIssues.has(issue.number) || validateIssueMutation.isPending) { |
| toast.info(`Validation already in progress for issue #${issue.number}`); |
| return; |
| } |
|
|
| |
| const cached = cachedValidations.get(issue.number); |
| if (cached && !forceRevalidate && !isValidationStale(cached.validatedAt)) { |
| |
| onValidationResultChange(cached.result); |
| onShowValidationDialogChange(true); |
| return; |
| } |
|
|
| |
| |
| const effectiveModelEntry = modelEntry |
| ? modelEntry |
| : model |
| ? typeof model === 'string' |
| ? { model: model as ModelId } |
| : model |
| : phaseModels.validationModel; |
| const normalizedEntry = |
| typeof effectiveModelEntry === 'string' |
| ? { model: effectiveModelEntry as ModelId } |
| : effectiveModelEntry; |
| const modelToUse = normalizedEntry.model; |
| const thinkingLevelToUse = normalizedEntry.thinkingLevel; |
| const reasoningEffortToUse = normalizedEntry.reasoningEffort; |
| const providerIdToUse = normalizedEntry.providerId; |
|
|
| |
| validateIssueMutation.mutate({ |
| issue, |
| model: modelToUse, |
| thinkingLevel: thinkingLevelToUse, |
| reasoningEffort: reasoningEffortToUse, |
| providerId: providerIdToUse, |
| comments, |
| linkedPRs, |
| }); |
| }, |
| [ |
| currentProject?.path, |
| validatingIssues, |
| cachedValidations, |
| phaseModels.validationModel, |
| validateIssueMutation, |
| onValidationResultChange, |
| onShowValidationDialogChange, |
| ] |
| ); |
|
|
| |
| const handleViewCachedValidation = useCallback( |
| async (issue: GitHubIssue) => { |
| const cached = cachedValidations.get(issue.number); |
| if (cached) { |
| onValidationResultChange(cached.result); |
| onShowValidationDialogChange(true); |
|
|
| |
| if (!cached.viewedAt && currentProject?.path) { |
| markViewedMutation.mutate(issue.number, { |
| onSuccess: () => { |
| |
| setCachedValidations((prev) => { |
| const next = new Map(prev); |
| const updated = prev.get(issue.number); |
| if (updated) { |
| next.set(issue.number, { |
| ...updated, |
| viewedAt: new Date().toISOString(), |
| }); |
| } |
| return next; |
| }); |
| }, |
| }); |
| } |
| } |
| }, |
| [ |
| cachedValidations, |
| currentProject?.path, |
| markViewedMutation, |
| onValidationResultChange, |
| onShowValidationDialogChange, |
| ] |
| ); |
|
|
| return { |
| validatingIssues, |
| cachedValidations, |
| handleValidateIssue, |
| handleViewCachedValidation, |
| isValidating: validateIssueMutation.isPending, |
| }; |
| } |
|
|