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/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService)); app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/github', createGitHubRoutes()); app.use('/api/github', createGitHubRoutes(events));
app.use('/api/context', createContextRoutes()); app.use('/api/context', createContextRoutes());
// Create HTTP server // 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 { Router } from 'express';
import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js'; import { validatePathParams } from '../../middleware/validate-paths.js';
import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'; import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js';
import { createListIssuesHandler } from './routes/list-issues.js'; import { createListIssuesHandler } from './routes/list-issues.js';
import { createListPRsHandler } from './routes/list-prs.js'; import { createListPRsHandler } from './routes/list-prs.js';
import { createValidateIssueHandler } from './routes/validate-issue.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(); const router = Router();
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler()); router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler()); router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler()); 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; 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. * 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 type { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk'; import { query } from '@anthropic-ai/claude-agent-sdk';
import { createLogger } from '@automaker/utils'; import type { EventEmitter } from '../../../lib/events.js';
import type { IssueValidationResult } from '@automaker/types'; import type { IssueValidationResult, IssueValidationEvent, AgentModel } from '@automaker/types';
import { createSuggestionsOptions } from '../../../lib/sdk-options.js'; import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
import { writeValidation } from '../../../lib/validation-storage.js';
import { import {
issueValidationSchema, issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT, ISSUE_VALIDATION_SYSTEM_PROMPT,
buildValidationPrompt, buildValidationPrompt,
} from './validation-schema.js'; } 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 * Request body for issue validation
@@ -27,6 +36,148 @@ interface ValidateIssueRequestBody {
issueTitle: string; issueTitle: string;
issueBody: string; issueBody: string;
issueLabels?: 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 * - Read-only tools (Read, Glob, Grep) for codebase analysis
* - JSON schema structured output for reliable parsing * - JSON schema structured output for reliable parsing
* - System prompt guiding the validation process * - 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> => { return async (req: Request, res: Response): Promise<void> => {
// Declare timeoutId outside try block for proper cleanup
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try { try {
const { projectPath, issueNumber, issueTitle, issueBody, issueLabels } = const {
req.body as ValidateIssueRequestBody; projectPath,
issueNumber,
issueTitle,
issueBody,
issueLabels,
model = 'opus',
} = req.body as ValidateIssueRequestBody;
// Validate required fields // Validate required fields
if (!projectPath) { if (!projectPath) {
@@ -69,90 +224,57 @@ export function createValidateIssueHandler() {
return; 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 logger.info(`Starting async validation for issue #${issueNumber}: ${issueTitle}`);
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
// 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 abortController = new AbortController();
const VALIDATION_TIMEOUT_MS = 360000; // 6 minutes if (!trySetValidationRunning(projectPath, issueNumber, abortController)) {
timeoutId = setTimeout(() => { res.json({
logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`); success: false,
abortController.abort(); error: `Validation is already running for issue #${issueNumber}`,
}, VALIDATION_TIMEOUT_MS); });
return;
// 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');
}
}
} }
// Require structured output - no fragile fallback parsing // Start validation in background (fire-and-forget)
if (!validationResult) { runValidation(
logger.error('No structured output received from Claude SDK'); projectPath,
logger.debug('Raw response text:', responseText); issueNumber,
throw new Error('Validation failed: no structured output received'); 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 // Return immediately
clearTimeout(timeoutId);
logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`);
res.json({ res.json({
success: true, success: true,
message: `Validation started for issue #${issueNumber}`,
issueNumber, issueNumber,
validation: validationResult,
}); });
} catch (error) { } catch (error) {
// Clear timeout on error as well (if it was set)
if (timeoutId) {
clearTimeout(timeoutId);
}
logError(error, `Issue validation failed`); logError(error, `Issue validation failed`);
logger.error('Issue validation error:', error); logger.error('Issue validation error:', error);
// Check if response already sent
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
success: false, 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 { import {
CircleDot, CircleDot,
Loader2, Loader2,
@@ -10,12 +10,16 @@ import {
Wand2, Wand2,
GitPullRequest, GitPullRequest,
User, User,
CheckCircle,
Clock,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getElectronAPI, getElectronAPI,
GitHubIssue, GitHubIssue,
IssueValidationResult, IssueValidationResult,
IssueComplexity, IssueComplexity,
IssueValidationEvent,
StoredValidation,
} from '@/lib/electron'; } from '@/lib/electron';
/** /**
@@ -48,10 +52,15 @@ export function GitHubIssuesView() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | 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 [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
const [showValidationDialog, setShowValidationDialog] = useState(false); 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 () => { const fetchIssues = useCallback(async () => {
if (!currentProject?.path) { if (!currentProject?.path) {
@@ -85,6 +94,125 @@ export function GitHubIssuesView() {
fetchIssues(); fetchIssues();
}, [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(() => { const handleRefresh = useCallback(() => {
setRefreshing(true); setRefreshing(true);
fetchIssues(); fetchIssues();
@@ -96,42 +224,83 @@ export function GitHubIssuesView() {
}, []); }, []);
const handleValidateIssue = useCallback( const handleValidateIssue = useCallback(
async (issue: GitHubIssue) => { async (issue: GitHubIssue, showDialog = true) => {
if (!currentProject?.path) { if (!currentProject?.path) {
toast.error('No project selected'); toast.error('No project selected');
return; 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); setValidationResult(null);
setShowValidationDialog(true); if (showDialog) {
setShowValidationDialog(true);
}
try { try {
const api = getElectronAPI(); const api = getElectronAPI();
if (api.github?.validateIssue) { if (api.github?.validateIssue) {
const result = await api.github.validateIssue(currentProject.path, { const result = await api.github.validateIssue(
issueNumber: issue.number, currentProject.path,
issueTitle: issue.title, {
issueBody: issue.body || '', issueNumber: issue.number,
issueLabels: issue.labels.map((l) => l.name), issueTitle: issue.title,
}); issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
},
validationModel
);
if (result.success) { if (!result.success) {
setValidationResult(result.validation); toast.error(result.error || 'Failed to start validation');
} else { if (showDialog) {
toast.error(result.error || 'Failed to validate issue'); setShowValidationDialog(false);
setShowValidationDialog(false); }
} }
// On success, the result will come through the event stream
} }
} catch (err) { } catch (err) {
console.error('[GitHubIssuesView] Validation error:', err); console.error('[GitHubIssuesView] Validation error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to validate issue'); toast.error(err instanceof Error ? err.message : 'Failed to validate issue');
setShowValidationDialog(false); if (showDialog) {
} finally { setShowValidationDialog(false);
setValidating(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( const handleConvertToTask = useCallback(
@@ -319,19 +488,79 @@ export function GitHubIssuesView() {
</span> </span>
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<Button {(() => {
variant="default" const isValidating = validatingIssues.has(selectedIssue.number);
size="sm" const cached = cachedValidations.get(selectedIssue.number);
onClick={() => handleValidateIssue(selectedIssue)} const isStale =
disabled={validating} cached &&
> (Date.now() - new Date(cached.validatedAt).getTime()) / (1000 * 60 * 60) > 24;
{validating ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> if (isValidating) {
) : ( return (
<Wand2 className="h-4 w-4 mr-1" /> <Button variant="default" size="sm" disabled>
)} <Loader2 className="h-4 w-4 mr-1 animate-spin" />
Validate with AI Validating...
</Button> </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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -484,7 +713,7 @@ export function GitHubIssuesView() {
onOpenChange={setShowValidationDialog} onOpenChange={setShowValidationDialog}
issue={selectedIssue} issue={selectedIssue}
validationResult={validationResult} validationResult={validationResult}
isValidating={validating} isValidating={selectedIssue ? validatingIssues.has(selectedIssue.number) : false}
onConvertToTask={handleConvertToTask} onConvertToTask={handleConvertToTask}
/> />
</div> </div>

View File

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

View File

@@ -12,6 +12,7 @@ import {
ScrollText, ScrollText,
ShieldCheck, ShieldCheck,
User, User,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@@ -22,6 +23,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import type { AIProfile } from '@/store/app-store'; import type { AIProfile } from '@/store/app-store';
import type { AgentModel } from '@automaker/types';
type PlanningMode = 'skip' | 'lite' | 'spec' | 'full'; type PlanningMode = 'skip' | 'lite' | 'spec' | 'full';
@@ -34,6 +36,7 @@ interface FeatureDefaultsSectionProps {
defaultRequirePlanApproval: boolean; defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null; defaultAIProfileId: string | null;
aiProfiles: AIProfile[]; aiProfiles: AIProfile[];
validationModel: AgentModel;
onShowProfilesOnlyChange: (value: boolean) => void; onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void; onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void; onEnableDependencyBlockingChange: (value: boolean) => void;
@@ -41,6 +44,7 @@ interface FeatureDefaultsSectionProps {
onDefaultPlanningModeChange: (value: PlanningMode) => void; onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void; onDefaultRequirePlanApprovalChange: (value: boolean) => void;
onDefaultAIProfileIdChange: (value: string | null) => void; onDefaultAIProfileIdChange: (value: string | null) => void;
onValidationModelChange: (value: AgentModel) => void;
} }
export function FeatureDefaultsSection({ export function FeatureDefaultsSection({
@@ -52,6 +56,7 @@ export function FeatureDefaultsSection({
defaultRequirePlanApproval, defaultRequirePlanApproval,
defaultAIProfileId, defaultAIProfileId,
aiProfiles, aiProfiles,
validationModel,
onShowProfilesOnlyChange, onShowProfilesOnlyChange,
onDefaultSkipTestsChange, onDefaultSkipTestsChange,
onEnableDependencyBlockingChange, onEnableDependencyBlockingChange,
@@ -59,6 +64,7 @@ export function FeatureDefaultsSection({
onDefaultPlanningModeChange, onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange, onDefaultRequirePlanApprovalChange,
onDefaultAIProfileIdChange, onDefaultAIProfileIdChange,
onValidationModelChange,
}: FeatureDefaultsSectionProps) { }: FeatureDefaultsSectionProps) {
// Find the selected profile name for display // Find the selected profile name for display
const selectedProfile = defaultAIProfileId const selectedProfile = defaultAIProfileId
@@ -227,6 +233,45 @@ export function FeatureDefaultsSection({
{/* Separator */} {/* Separator */}
<div className="border-t border-border/30" /> <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 */} {/* 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"> <div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox <Checkbox

View File

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

View File

@@ -8,6 +8,9 @@ import type {
IssueValidationInput, IssueValidationInput,
IssueValidationResult, IssueValidationResult,
IssueValidationResponse, IssueValidationResponse,
IssueValidationEvent,
StoredValidation,
AgentModel,
} from '@automaker/types'; } from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage'; import { getJSON, setJSON, removeItem } from './storage';
@@ -19,6 +22,8 @@ export type {
IssueValidationInput, IssueValidationInput,
IssueValidationResult, IssueValidationResult,
IssueValidationResponse, IssueValidationResponse,
IssueValidationEvent,
StoredValidation,
}; };
export interface FileEntry { export interface FileEntry {
@@ -189,10 +194,41 @@ export interface GitHubAPI {
mergedPRs?: GitHubPR[]; mergedPRs?: GitHubPR[];
error?: string; error?: string;
}>; }>;
/** Start async validation of a GitHub issue */
validateIssue: ( validateIssue: (
projectPath: string, projectPath: string,
issue: IssueValidationInput issue: IssueValidationInput,
) => Promise<IssueValidationResponse | { success: false; error: string }>; 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 // Feature Suggestions types
@@ -2640,6 +2676,8 @@ function createMockRunningAgentsAPI(): RunningAgentsAPI {
} }
// Mock GitHub API implementation // Mock GitHub API implementation
let mockValidationCallbacks: ((event: IssueValidationEvent) => void)[] = [];
function createMockGitHubAPI(): GitHubAPI { function createMockGitHubAPI(): GitHubAPI {
return { return {
checkRemote: async (projectPath: string) => { checkRemote: async (projectPath: string) => {
@@ -2668,20 +2706,71 @@ function createMockGitHubAPI(): GitHubAPI {
mergedPRs: [], mergedPRs: [],
}; };
}, },
validateIssue: async (projectPath: string, issue: IssueValidationInput) => { validateIssue: async (projectPath: string, issue: IssueValidationInput, model?: AgentModel) => {
console.log('[Mock] Validating GitHub issue:', { projectPath, issue }); console.log('[Mock] Starting async validation:', { projectPath, issue, model });
// Return a mock validation result
// 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 { return {
success: true as const, success: true,
message: `Validation started for issue #${issue.issueNumber}`,
issueNumber: issue.issueNumber, issueNumber: issue.issueNumber,
validation: { };
verdict: 'valid' as const, },
confidence: 'medium' as const, getValidationStatus: async (projectPath: string, issueNumber?: number) => {
reasoning: console.log('[Mock] Getting validation status:', { projectPath, issueNumber });
'This is a mock validation. In production, Claude SDK would analyze the codebase to validate this issue.', return {
relatedFiles: ['src/components/example.tsx'], success: true,
estimatedComplexity: 'moderate' as const, 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, GitHubIssue,
GitHubPR, GitHubPR,
IssueValidationInput, IssueValidationInput,
IssueValidationEvent,
} from './electron'; } from './electron';
import type { Message, SessionListItem } from '@/types/electron'; import type { Message, SessionListItem } from '@/types/electron';
import type { Feature, ClaudeUsageResponse } from '@/store/app-store'; import type { Feature, ClaudeUsageResponse } from '@/store/app-store';
@@ -52,7 +53,8 @@ type EventType =
| 'agent:stream' | 'agent:stream'
| 'auto-mode:event' | 'auto-mode:event'
| 'suggestions:event' | 'suggestions:event'
| 'spec-regeneration:event'; | 'spec-regeneration:event'
| 'issue-validation:event';
type EventCallback = (payload: unknown) => void; type EventCallback = (payload: unknown) => void;
@@ -752,8 +754,16 @@ export class HttpApiClient implements ElectronAPI {
checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }), checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }),
listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }), listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }),
listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }), listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }),
validateIssue: (projectPath: string, issue: IssueValidationInput) => validateIssue: (projectPath: string, issue: IssueValidationInput, model?: string) =>
this.post('/api/github/validate-issue', { projectPath, ...issue }), 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 // Workspace API

View File

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

View File

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

View File

@@ -111,6 +111,44 @@ export function getWorktreesDir(projectPath: string): string {
return path.join(getAutomakerDir(projectPath), 'worktrees'); 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 * Get the app spec file path for a project
* *

View File

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

View File

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

View File

@@ -4,6 +4,8 @@
* Types for validating GitHub issues against the codebase using Claude SDK. * Types for validating GitHub issues against the codebase using Claude SDK.
*/ */
import type { AgentModel } from './model.js';
/** /**
* Verdict from issue validation * Verdict from issue validation
*/ */
@@ -76,3 +78,49 @@ export interface IssueValidationErrorResponse {
success: false; success: false;
error: string; 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 // AI Model Selection
/** Which model to use for feature name/description enhancement */ /** Which model to use for feature name/description enhancement */
enhancementModel: AgentModel; enhancementModel: AgentModel;
/** Which model to use for GitHub issue validation */
validationModel: AgentModel;
// Input Configuration // Input Configuration
/** User's keyboard shortcut bindings */ /** User's keyboard shortcut bindings */
@@ -437,6 +439,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
defaultAIProfileId: null, defaultAIProfileId: null,
muteDoneSound: false, muteDoneSound: false,
enhancementModel: 'sonnet', enhancementModel: 'sonnet',
validationModel: 'opus',
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
aiProfiles: [], aiProfiles: [],
projects: [], projects: [],