feat: Implement GitHub issue validation management and UI enhancements

- Introduced CRUD operations for GitHub issue validation results, including storage and retrieval.
- Added new endpoints for checking validation status, stopping validations, and deleting stored validations.
- Enhanced the GitHub routes to support validation management features.
- Updated the UI to display validation results and manage validation states for GitHub issues.
- Integrated event handling for validation progress and completion notifications.
This commit is contained in:
Kacper
2025-12-23 18:15:30 +01:00
parent 5f0ecc8dd6
commit 6acb751eb3
19 changed files with 1279 additions and 137 deletions

View File

@@ -147,7 +147,7 @@ app.use('/api/templates', createTemplatesRoutes());
app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/github', createGitHubRoutes());
app.use('/api/github', createGitHubRoutes(events));
app.use('/api/context', createContextRoutes());
// Create HTTP server

View File

@@ -0,0 +1,147 @@
/**
* Validation Storage - CRUD operations for GitHub issue validation results
*
* Stores validation results in .automaker/validations/{issueNumber}/validation.json
* Results include the validation verdict, metadata, and timestamp for cache invalidation.
*/
import * as secureFs from './secure-fs.js';
import { getValidationsDir, getValidationDir, getValidationPath } from '@automaker/platform';
import type { StoredValidation } from '@automaker/types';
// Re-export StoredValidation for convenience
export type { StoredValidation };
/** Number of hours before a validation is considered stale */
const VALIDATION_CACHE_TTL_HOURS = 24;
/**
* Write validation result to storage
*
* Creates the validation directory if needed and stores the result as JSON.
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @param data - Validation data to store
*/
export async function writeValidation(
projectPath: string,
issueNumber: number,
data: StoredValidation
): Promise<void> {
const validationDir = getValidationDir(projectPath, issueNumber);
const validationPath = getValidationPath(projectPath, issueNumber);
// Ensure directory exists
await secureFs.mkdir(validationDir, { recursive: true });
// Write validation result
await secureFs.writeFile(validationPath, JSON.stringify(data, null, 2), 'utf-8');
}
/**
* Read validation result from storage
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns Stored validation or null if not found
*/
export async function readValidation(
projectPath: string,
issueNumber: number
): Promise<StoredValidation | null> {
try {
const validationPath = getValidationPath(projectPath, issueNumber);
const content = (await secureFs.readFile(validationPath, 'utf-8')) as string;
return JSON.parse(content) as StoredValidation;
} catch {
// File doesn't exist or can't be read
return null;
}
}
/**
* Get all stored validations for a project
*
* @param projectPath - Absolute path to project directory
* @returns Array of stored validations
*/
export async function getAllValidations(projectPath: string): Promise<StoredValidation[]> {
const validations: StoredValidation[] = [];
const validationsDir = getValidationsDir(projectPath);
try {
const dirs = await secureFs.readdir(validationsDir, { withFileTypes: true });
for (const dir of dirs) {
if (dir.isDirectory()) {
const issueNumber = parseInt(dir.name, 10);
if (!isNaN(issueNumber)) {
const validation = await readValidation(projectPath, issueNumber);
if (validation) {
validations.push(validation);
}
}
}
}
} catch {
// Directory doesn't exist
}
// Sort by issue number
validations.sort((a, b) => a.issueNumber - b.issueNumber);
return validations;
}
/**
* Delete a validation from storage
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns true if validation was deleted, false if not found
*/
export async function deleteValidation(projectPath: string, issueNumber: number): Promise<boolean> {
try {
const validationDir = getValidationDir(projectPath, issueNumber);
await secureFs.rm(validationDir, { recursive: true, force: true });
return true;
} catch {
return false;
}
}
/**
* Check if a validation is stale (older than TTL)
*
* @param validation - Stored validation to check
* @returns true if validation is older than 24 hours
*/
export function isValidationStale(validation: StoredValidation): boolean {
const validatedAt = new Date(validation.validatedAt);
const now = new Date();
const hoursDiff = (now.getTime() - validatedAt.getTime()) / (1000 * 60 * 60);
return hoursDiff > VALIDATION_CACHE_TTL_HOURS;
}
/**
* Get validation with freshness info
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns Object with validation and isStale flag, or null if not found
*/
export async function getValidationWithFreshness(
projectPath: string,
issueNumber: number
): Promise<{ validation: StoredValidation; isStale: boolean } | null> {
const validation = await readValidation(projectPath, issueNumber);
if (!validation) {
return null;
}
return {
validation,
isStale: isValidationStale(validation),
};
}

View File

@@ -3,19 +3,44 @@
*/
import { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
import { createListIssuesHandler } from './routes/list-issues.js';
import { createListPRsHandler } from './routes/list-prs.js';
import { createValidateIssueHandler } from './routes/validate-issue.js';
import {
createValidationStatusHandler,
createValidationStopHandler,
createGetValidationsHandler,
createDeleteValidationHandler,
} from './routes/validation-endpoints.js';
export function createGitHubRoutes(): Router {
export function createGitHubRoutes(events: EventEmitter): Router {
const router = Router();
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
router.post('/validate-issue', validatePathParams('projectPath'), createValidateIssueHandler());
router.post(
'/validate-issue',
validatePathParams('projectPath'),
createValidateIssueHandler(events)
);
// Validation management endpoints
router.post(
'/validation-status',
validatePathParams('projectPath'),
createValidationStatusHandler()
);
router.post('/validation-stop', validatePathParams('projectPath'), createValidationStopHandler());
router.post('/validations', validatePathParams('projectPath'), createGetValidationsHandler());
router.post(
'/validation-delete',
validatePathParams('projectPath'),
createDeleteValidationHandler()
);
return router;
}

View File

@@ -1,22 +1,31 @@
/**
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK
* POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK (async)
*
* Scans the codebase to determine if an issue is valid, invalid, or needs clarification.
* Runs asynchronously and emits events for progress and completion.
*/
import type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils';
import type { IssueValidationResult } from '@automaker/types';
import type { EventEmitter } from '../../../lib/events.js';
import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types';
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
import { writeValidation } from '../../../lib/validation-storage.js';
import {
issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT,
buildValidationPrompt,
} from './validation-schema.js';
import { getErrorMessage, logError } from './common.js';
import {
trySetValidationRunning,
clearValidationStatus,
getErrorMessage,
logError,
logger,
} from './validation-common.js';
const logger = createLogger('IssueValidation');
/** Valid model values for validation */
const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const;
/**
* Request body for issue validation
@@ -27,6 +36,148 @@ interface ValidateIssueRequestBody {
issueTitle: string;
issueBody: string;
issueLabels?: string[];
/** Model to use for validation (opus, sonnet, haiku) */
model?: AgentModel;
}
/**
* Run the validation asynchronously
*
* Emits events for start, progress, complete, and error.
* Stores result on completion.
*/
async function runValidation(
projectPath: string,
issueNumber: number,
issueTitle: string,
issueBody: string,
issueLabels: string[] | undefined,
model: AgentModel,
events: EventEmitter,
abortController: AbortController
): Promise<void> {
// Emit start event
const startEvent: IssueValidationEvent = {
type: 'issue_validation_start',
issueNumber,
issueTitle,
projectPath,
};
events.emit('issue-validation:event', startEvent);
// Set up timeout (6 minutes)
const VALIDATION_TIMEOUT_MS = 360000;
const timeoutId = setTimeout(() => {
logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`);
abortController.abort();
}, VALIDATION_TIMEOUT_MS);
try {
// Build the prompt
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
// Create SDK options with structured output and abort controller
const options = createSuggestionsOptions({
cwd: projectPath,
model,
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
abortController,
outputFormat: {
type: 'json_schema',
schema: issueValidationSchema as Record<string, unknown>,
},
});
// Execute the query
const stream = query({ prompt, options });
let validationResult: IssueValidationResult | null = null;
let responseText = '';
for await (const msg of stream) {
// Collect assistant text for debugging and emit progress
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
// Emit progress event
const progressEvent: IssueValidationEvent = {
type: 'issue_validation_progress',
issueNumber,
content: block.text,
projectPath,
};
events.emit('issue-validation:event', progressEvent);
}
}
}
// Extract structured output on success
if (msg.type === 'result' && msg.subtype === 'success') {
const resultMsg = msg as { structured_output?: IssueValidationResult };
if (resultMsg.structured_output) {
validationResult = resultMsg.structured_output;
logger.debug('Received structured output:', validationResult);
}
}
// Handle errors
if (msg.type === 'result') {
const resultMsg = msg as { subtype?: string };
if (resultMsg.subtype === 'error_max_structured_output_retries') {
logger.error('Failed to produce valid structured output after retries');
throw new Error('Could not produce valid validation output');
}
}
}
// Clear timeout
clearTimeout(timeoutId);
// Require structured output
if (!validationResult) {
logger.error('No structured output received from Claude SDK');
logger.debug('Raw response text:', responseText);
throw new Error('Validation failed: no structured output received');
}
logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`);
// Store the result
await writeValidation(projectPath, issueNumber, {
issueNumber,
issueTitle,
validatedAt: new Date().toISOString(),
model,
result: validationResult,
});
// Emit completion event
const completeEvent: IssueValidationEvent = {
type: 'issue_validation_complete',
issueNumber,
issueTitle,
result: validationResult,
projectPath,
};
events.emit('issue-validation:event', completeEvent);
} catch (error) {
clearTimeout(timeoutId);
const errorMessage = getErrorMessage(error);
logError(error, `Issue #${issueNumber} validation failed`);
// Emit error event
const errorEvent: IssueValidationEvent = {
type: 'issue_validation_error',
issueNumber,
error: errorMessage,
projectPath,
};
events.emit('issue-validation:event', errorEvent);
throw error;
}
}
/**
@@ -36,15 +187,19 @@ interface ValidateIssueRequestBody {
* - Read-only tools (Read, Glob, Grep) for codebase analysis
* - JSON schema structured output for reliable parsing
* - System prompt guiding the validation process
* - Async execution with event emission
*/
export function createValidateIssueHandler() {
export function createValidateIssueHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
// Declare timeoutId outside try block for proper cleanup
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
const { projectPath, issueNumber, issueTitle, issueBody, issueLabels } =
req.body as ValidateIssueRequestBody;
const {
projectPath,
issueNumber,
issueTitle,
issueBody,
issueLabels,
model = 'opus',
} = req.body as ValidateIssueRequestBody;
// Validate required fields
if (!projectPath) {
@@ -69,90 +224,57 @@ export function createValidateIssueHandler() {
return;
}
logger.info(`Validating issue #${issueNumber}: ${issueTitle}`);
// Validate model parameter at runtime
if (!VALID_MODELS.includes(model)) {
res.status(400).json({
success: false,
error: `Invalid model. Must be one of: ${VALID_MODELS.join(', ')}`,
});
return;
}
// Build the prompt
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
logger.info(`Starting async validation for issue #${issueNumber}: ${issueTitle}`);
// Create abort controller with 2 minute timeout for validation
// Create abort controller and atomically try to claim validation slot
// This prevents TOCTOU race conditions
const abortController = new AbortController();
const VALIDATION_TIMEOUT_MS = 360000; // 6 minutes
timeoutId = setTimeout(() => {
logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`);
abortController.abort();
}, VALIDATION_TIMEOUT_MS);
// Create SDK options with structured output and abort controller
const options = createSuggestionsOptions({
cwd: projectPath,
systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT,
abortController,
outputFormat: {
type: 'json_schema',
schema: issueValidationSchema as Record<string, unknown>,
},
});
// Execute the query
const stream = query({ prompt, options });
let validationResult: IssueValidationResult | null = null;
let responseText = '';
for await (const msg of stream) {
// Collect assistant text for debugging/fallback
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text') {
responseText += block.text;
}
}
}
// Extract structured output on success
if (msg.type === 'result' && msg.subtype === 'success') {
const resultMsg = msg as { structured_output?: IssueValidationResult };
if (resultMsg.structured_output) {
validationResult = resultMsg.structured_output;
logger.debug('Received structured output:', validationResult);
}
}
// Handle errors
if (msg.type === 'result') {
const resultMsg = msg as { subtype?: string };
if (resultMsg.subtype === 'error_max_structured_output_retries') {
logger.error('Failed to produce valid structured output after retries');
throw new Error('Could not produce valid validation output');
}
}
if (!trySetValidationRunning(projectPath, issueNumber, abortController)) {
res.json({
success: false,
error: `Validation is already running for issue #${issueNumber}`,
});
return;
}
// Require structured output - no fragile fallback parsing
if (!validationResult) {
logger.error('No structured output received from Claude SDK');
logger.debug('Raw response text:', responseText);
throw new Error('Validation failed: no structured output received');
}
// Start validation in background (fire-and-forget)
runValidation(
projectPath,
issueNumber,
issueTitle,
issueBody,
issueLabels,
model,
events,
abortController
)
.catch((error) => {
// Error is already handled inside runValidation (event emitted)
logger.debug('Validation error caught in background handler:', error);
})
.finally(() => {
clearValidationStatus(projectPath, issueNumber);
});
// Clear the timeout since we completed successfully
clearTimeout(timeoutId);
logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`);
// Return immediately
res.json({
success: true,
message: `Validation started for issue #${issueNumber}`,
issueNumber,
validation: validationResult,
});
} catch (error) {
// Clear timeout on error as well (if it was set)
if (timeoutId) {
clearTimeout(timeoutId);
}
logError(error, `Issue validation failed`);
logger.error('Issue validation error:', error);
// Check if response already sent
if (!res.headersSent) {
res.status(500).json({
success: false,

View File

@@ -0,0 +1,174 @@
/**
* Common utilities and state for issue validation routes
*
* Tracks running validation status per issue to support:
* - Checking if a validation is in progress
* - Cancelling a running validation
* - Preventing duplicate validations for the same issue
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../../common.js';
const logger = createLogger('IssueValidation');
/**
* Status of a validation in progress
*/
interface ValidationStatus {
isRunning: boolean;
abortController: AbortController;
startedAt: Date;
}
/**
* Map of issue number to validation status
* Key format: `${projectPath}||${issueNumber}` to support multiple projects
* Note: Using `||` as delimiter since `:` appears in Windows paths (e.g., C:\)
*/
const validationStatusMap = new Map<string, ValidationStatus>();
/** Maximum age for stale validation entries before cleanup (1 hour) */
const MAX_VALIDATION_AGE_MS = 60 * 60 * 1000;
/**
* Create a unique key for a validation
* Uses `||` as delimiter since `:` appears in Windows paths
*/
function getValidationKey(projectPath: string, issueNumber: number): string {
return `${projectPath}||${issueNumber}`;
}
/**
* Check if a validation is currently running for an issue
*/
export function isValidationRunning(projectPath: string, issueNumber: number): boolean {
const key = getValidationKey(projectPath, issueNumber);
const status = validationStatusMap.get(key);
return status?.isRunning ?? false;
}
/**
* Get validation status for an issue
*/
export function getValidationStatus(
projectPath: string,
issueNumber: number
): { isRunning: boolean; startedAt?: Date } | null {
const key = getValidationKey(projectPath, issueNumber);
const status = validationStatusMap.get(key);
if (!status) {
return null;
}
return {
isRunning: status.isRunning,
startedAt: status.startedAt,
};
}
/**
* Get all running validations for a project
*/
export function getRunningValidations(projectPath: string): number[] {
const runningIssues: number[] = [];
const prefix = `${projectPath}||`;
for (const [key, status] of validationStatusMap.entries()) {
if (status.isRunning && key.startsWith(prefix)) {
const issueNumber = parseInt(key.slice(prefix.length), 10);
if (!isNaN(issueNumber)) {
runningIssues.push(issueNumber);
}
}
}
return runningIssues;
}
/**
* Set a validation as running
*/
export function setValidationRunning(
projectPath: string,
issueNumber: number,
abortController: AbortController
): void {
const key = getValidationKey(projectPath, issueNumber);
validationStatusMap.set(key, {
isRunning: true,
abortController,
startedAt: new Date(),
});
}
/**
* Atomically try to set a validation as running (check-and-set)
* Prevents TOCTOU race conditions when starting validations
*
* @returns true if successfully claimed, false if already running
*/
export function trySetValidationRunning(
projectPath: string,
issueNumber: number,
abortController: AbortController
): boolean {
const key = getValidationKey(projectPath, issueNumber);
if (validationStatusMap.has(key)) {
return false; // Already running
}
validationStatusMap.set(key, {
isRunning: true,
abortController,
startedAt: new Date(),
});
return true; // Successfully claimed
}
/**
* Cleanup stale validation entries (e.g., from crashed validations)
* Should be called periodically to prevent memory leaks
*/
export function cleanupStaleValidations(): number {
const now = Date.now();
let cleanedCount = 0;
for (const [key, status] of validationStatusMap.entries()) {
if (now - status.startedAt.getTime() > MAX_VALIDATION_AGE_MS) {
status.abortController.abort();
validationStatusMap.delete(key);
cleanedCount++;
}
}
if (cleanedCount > 0) {
logger.info(`Cleaned up ${cleanedCount} stale validation entries`);
}
return cleanedCount;
}
/**
* Clear validation status (call when validation completes or errors)
*/
export function clearValidationStatus(projectPath: string, issueNumber: number): void {
const key = getValidationKey(projectPath, issueNumber);
validationStatusMap.delete(key);
}
/**
* Abort a running validation
*
* @returns true if validation was aborted, false if not running
*/
export function abortValidation(projectPath: string, issueNumber: number): boolean {
const key = getValidationKey(projectPath, issueNumber);
const status = validationStatusMap.get(key);
if (!status || !status.isRunning) {
return false;
}
status.abortController.abort();
validationStatusMap.delete(key);
return true;
}
// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);
export { logger };

View File

@@ -0,0 +1,190 @@
/**
* Additional validation endpoints for status, stop, and retrieving stored validations
*/
import type { Request, Response } from 'express';
import {
isValidationRunning,
getValidationStatus,
getRunningValidations,
abortValidation,
getErrorMessage,
logError,
logger,
} from './validation-common.js';
import {
readValidation,
getAllValidations,
getValidationWithFreshness,
deleteValidation,
} from '../../../lib/validation-storage.js';
/**
* POST /validation-status - Check if validation is running for an issue
*/
export function createValidationStatusHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber } = req.body as {
projectPath: string;
issueNumber?: number;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If issueNumber provided, check specific issue
if (issueNumber !== undefined) {
const status = getValidationStatus(projectPath, issueNumber);
res.json({
success: true,
isRunning: status?.isRunning ?? false,
startedAt: status?.startedAt?.toISOString(),
});
return;
}
// Otherwise, return all running validations for the project
const runningIssues = getRunningValidations(projectPath);
res.json({
success: true,
runningIssues,
});
} catch (error) {
logError(error, 'Validation status check failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* POST /validation-stop - Cancel a running validation
*/
export function createValidationStopHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber } = req.body as {
projectPath: string;
issueNumber: number;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!issueNumber || typeof issueNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'issueNumber is required and must be a number' });
return;
}
const wasAborted = abortValidation(projectPath, issueNumber);
if (wasAborted) {
logger.info(`Validation for issue #${issueNumber} was stopped`);
res.json({
success: true,
message: `Validation for issue #${issueNumber} has been stopped`,
});
} else {
res.json({
success: false,
error: `No validation is running for issue #${issueNumber}`,
});
}
} catch (error) {
logError(error, 'Validation stop failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* POST /validations - Get stored validations for a project
*/
export function createGetValidationsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber } = req.body as {
projectPath: string;
issueNumber?: number;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If issueNumber provided, get specific validation with freshness info
if (issueNumber !== undefined) {
const result = await getValidationWithFreshness(projectPath, issueNumber);
if (!result) {
res.json({
success: true,
validation: null,
});
return;
}
res.json({
success: true,
validation: result.validation,
isStale: result.isStale,
});
return;
}
// Otherwise, get all validations for the project
const validations = await getAllValidations(projectPath);
res.json({
success: true,
validations,
});
} catch (error) {
logError(error, 'Get validations failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* POST /validation-delete - Delete a stored validation
*/
export function createDeleteValidationHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber } = req.body as {
projectPath: string;
issueNumber: number;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!issueNumber || typeof issueNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'issueNumber is required and must be a number' });
return;
}
const deleted = await deleteValidation(projectPath, issueNumber);
res.json({
success: true,
deleted,
});
} catch (error) {
logError(error, 'Delete validation failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
CircleDot,
Loader2,
@@ -10,12 +10,16 @@ import {
Wand2,
GitPullRequest,
User,
CheckCircle,
Clock,
} from 'lucide-react';
import {
getElectronAPI,
GitHubIssue,
IssueValidationResult,
IssueComplexity,
IssueValidationEvent,
StoredValidation,
} from '@/lib/electron';
/**
@@ -48,10 +52,15 @@ export function GitHubIssuesView() {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
const [validating, setValidating] = useState(false);
const [validatingIssues, setValidatingIssues] = useState<Set<number>>(new Set());
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
const [showValidationDialog, setShowValidationDialog] = useState(false);
const { currentProject } = useAppStore();
// Track cached validations for display
const [cachedValidations, setCachedValidations] = useState<Map<number, StoredValidation>>(
new Map()
);
const audioRef = useRef<HTMLAudioElement | null>(null);
const { currentProject, validationModel, muteDoneSound } = useAppStore();
const fetchIssues = useCallback(async () => {
if (!currentProject?.path) {
@@ -85,6 +94,125 @@ export function GitHubIssuesView() {
fetchIssues();
}, [fetchIssues]);
// Load cached validations on mount
useEffect(() => {
const loadCachedValidations = async () => {
if (!currentProject?.path) return;
try {
const api = getElectronAPI();
if (api.github?.getValidations) {
const result = await api.github.getValidations(currentProject.path);
if (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) {
console.error('[GitHubIssuesView] Failed to load cached validations:', err);
}
};
loadCachedValidations();
}, [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.issueTitle to avoid stale closure)
setCachedValidations((prev) => {
const next = new Map(prev);
next.set(event.issueNumber, {
issueNumber: event.issueNumber,
issueTitle: event.issueTitle,
validatedAt: new Date().toISOString(),
model: validationModel,
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 (selectedIssue?.number === event.issueNumber && showValidationDialog) {
setValidationResult(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 (selectedIssue?.number === event.issueNumber && showValidationDialog) {
setShowValidationDialog(false);
}
break;
}
};
const unsubscribe = api.github.onValidationEvent(handleValidationEvent);
return () => unsubscribe();
}, [currentProject?.path, selectedIssue, showValidationDialog, validationModel, muteDoneSound]);
// Cleanup audio element on unmount to prevent memory leaks
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchIssues();
@@ -96,42 +224,83 @@ export function GitHubIssuesView() {
}, []);
const handleValidateIssue = useCallback(
async (issue: GitHubIssue) => {
async (issue: GitHubIssue, showDialog = true) => {
if (!currentProject?.path) {
toast.error('No project selected');
return;
}
setValidating(true);
// 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
const cached = cachedValidations.get(issue.number);
if (cached && showDialog) {
// Check if validation is stale (older than 24 hours)
const validatedAt = new Date(cached.validatedAt);
const hoursSinceValidation = (Date.now() - validatedAt.getTime()) / (1000 * 60 * 60);
const isStale = hoursSinceValidation > 24;
if (!isStale) {
// Show cached result directly
setValidationResult(cached.result);
setShowValidationDialog(true);
return;
}
}
// Start async validation
setValidationResult(null);
setShowValidationDialog(true);
if (showDialog) {
setShowValidationDialog(true);
}
try {
const api = getElectronAPI();
if (api.github?.validateIssue) {
const result = await api.github.validateIssue(currentProject.path, {
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
});
const result = await api.github.validateIssue(
currentProject.path,
{
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
},
validationModel
);
if (result.success) {
setValidationResult(result.validation);
} else {
toast.error(result.error || 'Failed to validate issue');
setShowValidationDialog(false);
if (!result.success) {
toast.error(result.error || 'Failed to start validation');
if (showDialog) {
setShowValidationDialog(false);
}
}
// On success, the result will come through the event stream
}
} catch (err) {
console.error('[GitHubIssuesView] Validation error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to validate issue');
setShowValidationDialog(false);
} finally {
setValidating(false);
if (showDialog) {
setShowValidationDialog(false);
}
}
},
[currentProject?.path]
[currentProject?.path, validatingIssues, cachedValidations, validationModel]
);
// View cached validation result
const handleViewCachedValidation = useCallback(
(issue: GitHubIssue) => {
const cached = cachedValidations.get(issue.number);
if (cached) {
setValidationResult(cached.result);
setShowValidationDialog(true);
}
},
[cachedValidations]
);
const handleConvertToTask = useCallback(
@@ -319,19 +488,79 @@ export function GitHubIssuesView() {
</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="default"
size="sm"
onClick={() => handleValidateIssue(selectedIssue)}
disabled={validating}
>
{validating ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Wand2 className="h-4 w-4 mr-1" />
)}
Validate with AI
</Button>
{(() => {
const isValidating = validatingIssues.has(selectedIssue.number);
const cached = cachedValidations.get(selectedIssue.number);
const isStale =
cached &&
(Date.now() - new Date(cached.validatedAt).getTime()) / (1000 * 60 * 60) > 24;
if (isValidating) {
return (
<Button variant="default" size="sm" disabled>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Validating...
</Button>
);
}
if (cached && !isStale) {
return (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleViewCachedValidation(selectedIssue)}
>
<CheckCircle className="h-4 w-4 mr-1 text-green-500" />
View Result
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleValidateIssue(selectedIssue)}
title="Re-validate"
>
<RefreshCw className="h-4 w-4" />
</Button>
</>
);
}
if (cached && isStale) {
return (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleViewCachedValidation(selectedIssue)}
>
<Clock className="h-4 w-4 mr-1 text-yellow-500" />
View (stale)
</Button>
<Button
variant="default"
size="sm"
onClick={() => handleValidateIssue(selectedIssue)}
>
<Wand2 className="h-4 w-4 mr-1" />
Re-validate
</Button>
</>
);
}
return (
<Button
variant="default"
size="sm"
onClick={() => handleValidateIssue(selectedIssue)}
>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
</Button>
);
})()}
<Button
variant="outline"
size="sm"
@@ -484,7 +713,7 @@ export function GitHubIssuesView() {
onOpenChange={setShowValidationDialog}
issue={selectedIssue}
validationResult={validationResult}
isValidating={validating}
isValidating={selectedIssue ? validatingIssues.has(selectedIssue.number) : false}
onConvertToTask={handleConvertToTask}
/>
</div>

View File

@@ -45,6 +45,8 @@ export function SettingsView() {
setDefaultAIProfileId,
aiProfiles,
apiKeys,
validationModel,
setValidationModel,
} = useAppStore();
// Hide usage tracking when using API key (only show for Claude Code CLI users)
@@ -134,6 +136,7 @@ export function SettingsView() {
defaultRequirePlanApproval={defaultRequirePlanApproval}
defaultAIProfileId={defaultAIProfileId}
aiProfiles={aiProfiles}
validationModel={validationModel}
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
@@ -141,6 +144,7 @@ export function SettingsView() {
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
onDefaultAIProfileIdChange={setDefaultAIProfileId}
onValidationModelChange={setValidationModel}
/>
);
case 'danger':

View File

@@ -12,6 +12,7 @@ import {
ScrollText,
ShieldCheck,
User,
Sparkles,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
@@ -22,6 +23,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import type { AIProfile } from '@/store/app-store';
import type { AgentModel } from '@automaker/types';
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
@@ -34,6 +36,7 @@ interface FeatureDefaultsSectionProps {
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
aiProfiles: AIProfile[];
validationModel: AgentModel;
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
@@ -41,6 +44,7 @@ interface FeatureDefaultsSectionProps {
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onDefaultAIProfileIdChange: (value: string | null) => void;
onValidationModelChange: (value: AgentModel) => void;
}
export function FeatureDefaultsSection({
@@ -52,6 +56,7 @@ export function FeatureDefaultsSection({
defaultRequirePlanApproval,
defaultAIProfileId,
aiProfiles,
validationModel,
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
@@ -59,6 +64,7 @@ export function FeatureDefaultsSection({
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
onDefaultAIProfileIdChange,
onValidationModelChange,
}: FeatureDefaultsSectionProps) {
// Find the selected profile name for display
const selectedProfile = defaultAIProfileId
@@ -227,6 +233,45 @@ export function FeatureDefaultsSection({
{/* Separator */}
<div className="border-t border-border/30" />
{/* Issue Validation Model */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-purple-500/10">
<Sparkles className="w-5 h-5 text-purple-500" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-foreground font-medium">Issue Validation Model</Label>
<Select
value={validationModel}
onValueChange={(v: string) => onValidationModelChange(v as AgentModel)}
>
<SelectTrigger className="w-[140px] h-8" data-testid="validation-model-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="opus">
<span>Opus</span>
<span className="text-[10px] text-muted-foreground ml-1">(Default)</span>
</SelectItem>
<SelectItem value="sonnet">
<span>Sonnet</span>
</SelectItem>
<SelectItem value="haiku">
<span>Haiku</span>
</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Model used for validating GitHub issues. Opus provides the most thorough analysis,
while Haiku is faster and more cost-effective.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Profiles Only Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox

View File

@@ -222,6 +222,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
defaultAIProfileId: state.defaultAIProfileId,
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
projects: state.projects,

View File

@@ -8,6 +8,9 @@ import type {
IssueValidationInput,
IssueValidationResult,
IssueValidationResponse,
IssueValidationEvent,
StoredValidation,
AgentModel,
} from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage';
@@ -19,6 +22,8 @@ export type {
IssueValidationInput,
IssueValidationResult,
IssueValidationResponse,
IssueValidationEvent,
StoredValidation,
};
export interface FileEntry {
@@ -189,10 +194,41 @@ export interface GitHubAPI {
mergedPRs?: GitHubPR[];
error?: string;
}>;
/** Start async validation of a GitHub issue */
validateIssue: (
projectPath: string,
issue: IssueValidationInput
) => Promise<IssueValidationResponse | { success: false; error: string }>;
issue: IssueValidationInput,
model?: AgentModel
) => Promise<{ success: boolean; message?: string; issueNumber?: number; error?: string }>;
/** Check validation status for an issue or all issues */
getValidationStatus: (
projectPath: string,
issueNumber?: number
) => Promise<{
success: boolean;
isRunning?: boolean;
startedAt?: string;
runningIssues?: number[];
error?: string;
}>;
/** Stop a running validation */
stopValidation: (
projectPath: string,
issueNumber: number
) => Promise<{ success: boolean; message?: string; error?: string }>;
/** Get stored validations for a project */
getValidations: (
projectPath: string,
issueNumber?: number
) => Promise<{
success: boolean;
validation?: StoredValidation | null;
validations?: StoredValidation[];
isStale?: boolean;
error?: string;
}>;
/** Subscribe to validation events */
onValidationEvent: (callback: (event: IssueValidationEvent) => void) => () => void;
}
// Feature Suggestions types
@@ -2640,6 +2676,8 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
}
// Mock GitHub API implementation
let mockValidationCallbacks: ((event: IssueValidationEvent) => void)[] = [];
function createMockGitHubAPI(): GitHubAPI {
return {
checkRemote: async (projectPath: string) => {
@@ -2668,20 +2706,71 @@ function createMockGitHubAPI(): GitHubAPI {
mergedPRs: [],
};
},
validateIssue: async (projectPath: string, issue: IssueValidationInput) => {
console.log('[Mock] Validating GitHub issue:', { projectPath, issue });
// Return a mock validation result
validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: AgentModel) => {
console.log('[Mock] Starting async validation:', { projectPath, issue, model });
// Simulate async validation in background
setTimeout(() => {
mockValidationCallbacks.forEach((cb) =>
cb({
type: 'issue_validation_start',
issueNumber: issue.issueNumber,
issueTitle: issue.issueTitle,
projectPath,
})
);
setTimeout(() => {
mockValidationCallbacks.forEach((cb) =>
cb({
type: 'issue_validation_complete',
issueNumber: issue.issueNumber,
result: {
verdict: 'valid' as const,
confidence: 'medium' as const,
reasoning:
'This is a mock validation. In production, Claude SDK would analyze the codebase to validate this issue.',
relatedFiles: ['src/components/example.tsx'],
estimatedComplexity: 'moderate' as const,
},
projectPath,
})
);
}, 2000);
}, 100);
return {
success: true as const,
success: true,
message: `Validation started for issue #${issue.issueNumber}`,
issueNumber: issue.issueNumber,
validation: {
verdict: 'valid' as const,
confidence: 'medium' as const,
reasoning:
'This is a mock validation. In production, Claude SDK would analyze the codebase to validate this issue.',
relatedFiles: ['src/components/example.tsx'],
estimatedComplexity: 'moderate' as const,
},
};
},
getValidationStatus: async (projectPath: string, issueNumber?: number) => {
console.log('[Mock] Getting validation status:', { projectPath, issueNumber });
return {
success: true,
isRunning: false,
runningIssues: [],
};
},
stopValidation: async (projectPath: string, issueNumber: number) => {
console.log('[Mock] Stopping validation:', { projectPath, issueNumber });
return {
success: true,
message: `Validation for issue #${issueNumber} stopped`,
};
},
getValidations: async (projectPath: string, issueNumber?: number) => {
console.log('[Mock] Getting validations:', { projectPath, issueNumber });
return {
success: true,
validations: [],
};
},
onValidationEvent: (callback: (event: IssueValidationEvent) => void) => {
mockValidationCallbacks.push(callback);
return () => {
mockValidationCallbacks = mockValidationCallbacks.filter((cb) => cb !== callback);
};
},
};

View File

@@ -25,6 +25,7 @@ import type {
GitHubIssue,
GitHubPR,
IssueValidationInput,
IssueValidationEvent,
} from './electron';
import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
@@ -52,7 +53,8 @@ type EventType =
| 'agent:stream'
| 'auto-mode:event'
| 'suggestions:event'
| 'spec-regeneration:event';
| 'spec-regeneration:event'
| 'issue-validation:event';
type EventCallback = (payload: unknown) => void;
@@ -752,8 +754,16 @@ export class HttpApiClient implements ElectronAPI {
checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }),
listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }),
listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }),
validateIssue: (projectPath: string, issue: IssueValidationInput) =>
this.post('/api/github/validate-issue', { projectPath, ...issue }),
validateIssue: (projectPath: string, issue: IssueValidationInput, model?: string) =>
this.post('/api/github/validate-issue', { projectPath, ...issue, model }),
getValidationStatus: (projectPath: string, issueNumber?: number) =>
this.post('/api/github/validation-status', { projectPath, issueNumber }),
stopValidation: (projectPath: string, issueNumber: number) =>
this.post('/api/github/validation-stop', { projectPath, issueNumber }),
getValidations: (projectPath: string, issueNumber?: number) =>
this.post('/api/github/validations', { projectPath, issueNumber }),
onValidationEvent: (callback: (event: IssueValidationEvent) => void) =>
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
};
// Workspace API

View File

@@ -475,6 +475,9 @@ export interface AppState {
// Enhancement Model Settings
enhancementModel: AgentModel; // Model used for feature enhancement (default: sonnet)
// Validation Model Settings
validationModel: AgentModel; // Model used for GitHub issue validation (default: opus)
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
@@ -745,6 +748,9 @@ export interface AppActions {
// Enhancement Model actions
setEnhancementModel: (model: AgentModel) => void;
// Validation Model actions
setValidationModel: (model: AgentModel) => void;
// AI Profile actions
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
@@ -915,6 +921,7 @@ const initialState: AppState = {
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
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
aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null,
isAnalyzing: false,
@@ -1537,6 +1544,9 @@ export const useAppStore = create<AppState & AppActions>()(
// Enhancement Model actions
setEnhancementModel: (model) => set({ enhancementModel: model }),
// Validation Model actions
setValidationModel: (model) => set({ validationModel: model }),
// AI Profile actions
addAIProfile: (profile) => {
const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -2679,6 +2689,7 @@ export const useAppStore = create<AppState & AppActions>()(
keyboardShortcuts: state.keyboardShortcuts,
muteDoneSound: state.muteDoneSound,
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
// Profiles and sessions
aiProfiles: state.aiProfiles,
chatSessions: state.chatSessions,

View File

@@ -13,6 +13,9 @@ export {
getImagesDir,
getContextDir,
getWorktreesDir,
getValidationsDir,
getValidationDir,
getValidationPath,
getAppSpecPath,
getBranchTrackingPath,
ensureAutomakerDir,

View File

@@ -111,6 +111,44 @@ export function getWorktreesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), 'worktrees');
}
/**
* Get the validations directory for a project
*
* Stores GitHub issue validation results, organized by issue number.
*
* @param projectPath - Absolute path to project directory
* @returns Absolute path to {projectPath}/.automaker/validations
*/
export function getValidationsDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), 'validations');
}
/**
* Get the directory for a specific issue validation
*
* Contains validation result and metadata for a GitHub issue.
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns Absolute path to {projectPath}/.automaker/validations/{issueNumber}
*/
export function getValidationDir(projectPath: string, issueNumber: number): string {
return path.join(getValidationsDir(projectPath), String(issueNumber));
}
/**
* Get the validation result file path for a GitHub issue
*
* Stores the JSON validation result including verdict, analysis, and metadata.
*
* @param projectPath - Absolute path to project directory
* @param issueNumber - GitHub issue number
* @returns Absolute path to {projectPath}/.automaker/validations/{issueNumber}/validation.json
*/
export function getValidationPath(projectPath: string, issueNumber: number): string {
return path.join(getValidationDir(projectPath, issueNumber), 'validation.json');
}
/**
* Get the app spec file path for a project
*

View File

@@ -24,6 +24,7 @@ export type EventType =
| 'project:analysis-completed'
| 'project:analysis-error'
| 'suggestions:event'
| 'spec-regeneration:event';
| 'spec-regeneration:event'
| 'issue-validation:event';
export type EventCallback = (type: EventType, payload: unknown) => void;

View File

@@ -92,4 +92,6 @@ export type {
IssueValidationResult,
IssueValidationResponse,
IssueValidationErrorResponse,
IssueValidationEvent,
StoredValidation,
} from './issue-validation.js';

View File

@@ -4,6 +4,8 @@
* Types for validating GitHub issues against the codebase using Claude SDK.
*/
import type { AgentModel } from './model.js';
/**
* Verdict from issue validation
*/
@@ -76,3 +78,49 @@ export interface IssueValidationErrorResponse {
success: false;
error: string;
}
/**
* Events emitted during async issue validation
*/
export type IssueValidationEvent =
| {
type: 'issue_validation_start';
issueNumber: number;
issueTitle: string;
projectPath: string;
}
| {
type: 'issue_validation_progress';
issueNumber: number;
content: string;
projectPath: string;
}
| {
type: 'issue_validation_complete';
issueNumber: number;
issueTitle: string;
result: IssueValidationResult;
projectPath: string;
}
| {
type: 'issue_validation_error';
issueNumber: number;
error: string;
projectPath: string;
};
/**
* Stored validation data with metadata for cache
*/
export interface StoredValidation {
/** GitHub issue number */
issueNumber: number;
/** Issue title at time of validation */
issueTitle: string;
/** ISO timestamp when validation was performed */
validatedAt: string;
/** Model used for validation (opus, sonnet, haiku) */
model: AgentModel;
/** The validation result */
result: IssueValidationResult;
}

View File

@@ -261,6 +261,8 @@ export interface GlobalSettings {
// AI Model Selection
/** Which model to use for feature name/description enhancement */
enhancementModel: AgentModel;
/** Which model to use for GitHub issue validation */
validationModel: AgentModel;
// Input Configuration
/** User's keyboard shortcut bindings */
@@ -437,6 +439,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
defaultAIProfileId: null,
muteDoneSound: false,
enhancementModel: 'sonnet',
validationModel: 'opus',
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
aiProfiles: [],
projects: [],