Files
automaker/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts
Shirone 69f3ba9724 feat: standardize logging across UI components
- Replaced console.log and console.error statements with logger methods from @automaker/utils in various UI components, ensuring consistent log formatting and improved readability.
- Enhanced error handling by utilizing logger methods to provide clearer context for issues encountered during operations.
- Updated multiple views and hooks to integrate the new logging system, improving maintainability and debugging capabilities.

This update significantly enhances the observability of UI components, facilitating easier troubleshooting and monitoring.
2026-01-02 17:33:15 +01:00

351 lines
11 KiB
TypeScript

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, ModelAlias, CursorModelId } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
const logger = createLogger('IssueValidation');
/**
* Extract model string from PhaseModelEntry or string (handles both formats)
*/
function extractModel(
entry: PhaseModelEntry | string | undefined
): ModelAlias | CursorModelId | undefined {
if (!entry) return undefined;
if (typeof entry === 'string') {
return entry as ModelAlias | CursorModelId;
}
return entry.model;
}
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);
// Refs for stable event handler (avoids re-subscribing on state changes)
const selectedIssueRef = useRef<GitHubIssue | null>(null);
const showValidationDialogRef = useRef(false);
// Keep refs in sync with state for stable event handler
useEffect(() => {
selectedIssueRef.current = selectedIssue;
}, [selectedIssue]);
useEffect(() => {
showValidationDialogRef.current = showValidationDialog;
}, [showValidationDialog]);
// Load cached validations on mount
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]);
// Load running validations on mount (restore validatingIssues state)
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]);
// Subscribe to validation events
useEffect(() => {
const api = getElectronAPI();
if (!api.github?.onValidationEvent) return;
const handleValidationEvent = (event: IssueValidationEvent) => {
// Only handle events for current project
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;
});
// Update cached validations (use event.model to avoid stale closure race condition)
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;
});
// Show toast notification
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',
});
// Play audio notification (if not muted)
if (!muteDoneSound) {
try {
if (!audioRef.current) {
audioRef.current = new Audio('/sounds/ding.mp3');
}
audioRef.current.play().catch(() => {
// Audio play might fail due to browser restrictions
});
} catch {
// Ignore audio errors
}
}
// If validation dialog is open for this issue, update the result
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]);
// Cleanup audio element on unmount to prevent memory leaks
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
const handleValidateIssue = useCallback(
async (
issue: GitHubIssue,
options: {
forceRevalidate?: boolean;
model?: string;
comments?: GitHubComment[];
linkedPRs?: LinkedPRInfo[];
} = {}
) => {
const { forceRevalidate = false, model, comments, linkedPRs } = options;
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
// Check if already validating this issue
if (validatingIssues.has(issue.number)) {
toast.info(`Validation already in progress for issue #${issue.number}`);
return;
}
// Check for cached result - if fresh, show it directly (unless force revalidate)
const cached = cachedValidations.get(issue.number);
if (cached && !forceRevalidate && !isValidationStale(cached.validatedAt)) {
// Show cached result directly
onValidationResultChange(cached.result);
onShowValidationDialogChange(true);
return;
}
// Start async validation in background (no dialog - user will see badge when done)
toast.info(`Starting validation for issue #${issue.number}`, {
description: 'You will be notified when the analysis is complete',
});
// Use provided model override or fall back to phaseModels.validationModel
// Extract model string from PhaseModelEntry (handles both old string format and new object format)
const modelToUse = model || extractModel(phaseModels.validationModel);
try {
const api = getElectronAPI();
if (api.github?.validateIssue) {
const validationInput = {
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
comments, // Include comments if provided
linkedPRs, // Include linked PRs if provided
};
const result = await api.github.validateIssue(
currentProject.path,
validationInput,
modelToUse
);
if (!result.success) {
toast.error(result.error || 'Failed to start validation');
}
// On success, the result will come through the event stream
}
} catch (err) {
logger.error('Validation error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to validate issue');
}
},
[
currentProject?.path,
validatingIssues,
cachedValidations,
phaseModels.validationModel,
onValidationResultChange,
onShowValidationDialogChange,
]
);
// View cached validation result
const handleViewCachedValidation = useCallback(
async (issue: GitHubIssue) => {
const cached = cachedValidations.get(issue.number);
if (cached) {
onValidationResultChange(cached.result);
onShowValidationDialogChange(true);
// Mark as viewed if not already viewed
if (!cached.viewedAt && currentProject?.path) {
try {
const api = getElectronAPI();
if (api.github?.markValidationViewed) {
await api.github.markValidationViewed(currentProject.path, issue.number);
// Update local state
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;
});
}
} catch (err) {
logger.error('Failed to mark validation as viewed:', err);
}
}
}
},
[
cachedValidations,
currentProject?.path,
onValidationResultChange,
onShowValidationDialogChange,
]
);
return {
validatingIssues,
cachedValidations,
handleValidateIssue,
handleViewCachedValidation,
};
}