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