mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
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:
@@ -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
|
||||||
|
|||||||
147
apps/server/src/lib/validation-storage.ts
Normal file
147
apps/server/src/lib/validation-storage.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
174
apps/server/src/routes/github/routes/validation-common.ts
Normal file
174
apps/server/src/routes/github/routes/validation-common.ts
Normal 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 };
|
||||||
190
apps/server/src/routes/github/routes/validation-endpoints.ts
Normal file
190
apps/server/src/routes/github/routes/validation-endpoints.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export {
|
|||||||
getImagesDir,
|
getImagesDir,
|
||||||
getContextDir,
|
getContextDir,
|
||||||
getWorktreesDir,
|
getWorktreesDir,
|
||||||
|
getValidationsDir,
|
||||||
|
getValidationDir,
|
||||||
|
getValidationPath,
|
||||||
getAppSpecPath,
|
getAppSpecPath,
|
||||||
getBranchTrackingPath,
|
getBranchTrackingPath,
|
||||||
ensureAutomakerDir,
|
ensureAutomakerDir,
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -92,4 +92,6 @@ export type {
|
|||||||
IssueValidationResult,
|
IssueValidationResult,
|
||||||
IssueValidationResponse,
|
IssueValidationResponse,
|
||||||
IssueValidationErrorResponse,
|
IssueValidationErrorResponse,
|
||||||
|
IssueValidationEvent,
|
||||||
|
StoredValidation,
|
||||||
} from './issue-validation.js';
|
} from './issue-validation.js';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user