| |
| |
| |
| |
| |
| |
| |
|
|
| import type { Request, Response } from 'express'; |
| import type { EventEmitter } from '../../../lib/events.js'; |
| import type { |
| IssueValidationResult, |
| IssueValidationEvent, |
| ModelId, |
| GitHubComment, |
| LinkedPRInfo, |
| ThinkingLevel, |
| ReasoningEffort, |
| } from '@automaker/types'; |
| import { |
| DEFAULT_PHASE_MODELS, |
| isClaudeModel, |
| isCodexModel, |
| isCursorModel, |
| isOpencodeModel, |
| supportsStructuredOutput, |
| } from '@automaker/types'; |
| import { resolvePhaseModel, resolveModelString } from '@automaker/model-resolver'; |
| import { extractJson } from '../../../lib/json-extractor.js'; |
| import { writeValidation } from '../../../lib/validation-storage.js'; |
| import { streamingQuery } from '../../../providers/simple-query-service.js'; |
| import { |
| issueValidationSchema, |
| buildValidationPrompt, |
| ValidationComment, |
| ValidationLinkedPR, |
| } from './validation-schema.js'; |
| import { |
| getPromptCustomization, |
| getAutoLoadClaudeMdSetting, |
| resolveProviderContext, |
| } from '../../../lib/settings-helpers.js'; |
| import { |
| trySetValidationRunning, |
| clearValidationStatus, |
| getErrorMessage, |
| logError, |
| logger, |
| } from './validation-common.js'; |
| import type { SettingsService } from '../../../services/settings-service.js'; |
|
|
| |
| |
| |
| interface ValidateIssueRequestBody { |
| projectPath: string; |
| issueNumber: number; |
| issueTitle: string; |
| issueBody: string; |
| issueLabels?: string[]; |
| |
| model?: ModelId; |
| |
| thinkingLevel?: ThinkingLevel; |
| |
| reasoningEffort?: ReasoningEffort; |
| |
| providerId?: string; |
| |
| comments?: GitHubComment[]; |
| |
| linkedPRs?: LinkedPRInfo[]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async function runValidation( |
| projectPath: string, |
| issueNumber: number, |
| issueTitle: string, |
| issueBody: string, |
| issueLabels: string[] | undefined, |
| model: ModelId, |
| events: EventEmitter, |
| abortController: AbortController, |
| settingsService?: SettingsService, |
| providerId?: string, |
| comments?: ValidationComment[], |
| linkedPRs?: ValidationLinkedPR[], |
| thinkingLevel?: ThinkingLevel, |
| reasoningEffort?: ReasoningEffort |
| ): Promise<void> { |
| |
| const startEvent: IssueValidationEvent = { |
| type: 'issue_validation_start', |
| issueNumber, |
| issueTitle, |
| projectPath, |
| }; |
| events.emit('issue-validation:event', startEvent); |
|
|
| |
| const VALIDATION_TIMEOUT_MS = 360000; |
| const timeoutId = setTimeout(() => { |
| logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`); |
| abortController.abort(); |
| }, VALIDATION_TIMEOUT_MS); |
|
|
| try { |
| |
| const basePrompt = buildValidationPrompt( |
| issueNumber, |
| issueTitle, |
| issueBody, |
| issueLabels, |
| comments, |
| linkedPRs |
| ); |
|
|
| let responseText = ''; |
|
|
| |
| const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]'); |
| const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt; |
|
|
| |
| |
| const useStructuredOutput = supportsStructuredOutput(model); |
|
|
| |
| let finalPrompt = basePrompt; |
| if (!useStructuredOutput) { |
| finalPrompt = `${issueValidationSystemPrompt} |
| |
| CRITICAL INSTRUCTIONS: |
| 1. DO NOT write any files. Return the JSON in your response only. |
| 2. Respond with ONLY a JSON object - no explanations, no markdown, just raw JSON. |
| 3. The JSON must match this exact schema: |
| |
| ${JSON.stringify(issueValidationSchema, null, 2)} |
| |
| Your entire response should be valid JSON starting with { and ending with }. No text before or after. |
| |
| ${basePrompt}`; |
| } |
|
|
| |
| const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( |
| projectPath, |
| settingsService, |
| '[ValidateIssue]' |
| ); |
|
|
| |
| let effectiveThinkingLevel: ThinkingLevel | undefined = thinkingLevel; |
| let effectiveReasoningEffort: ReasoningEffort | undefined = reasoningEffort; |
| if (!effectiveThinkingLevel || !effectiveReasoningEffort) { |
| const settings = await settingsService?.getGlobalSettings(); |
| const phaseModelEntry = |
| settings?.phaseModels?.validationModel || DEFAULT_PHASE_MODELS.validationModel; |
| const resolved = resolvePhaseModel(phaseModelEntry); |
| if (!effectiveThinkingLevel) { |
| effectiveThinkingLevel = resolved.thinkingLevel; |
| } |
| if (!effectiveReasoningEffort && typeof phaseModelEntry !== 'string') { |
| effectiveReasoningEffort = phaseModelEntry.reasoningEffort; |
| } |
| } |
|
|
| |
| |
| let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; |
| let providerResolvedModel: string | undefined; |
| let credentials = await settingsService?.getCredentials(); |
|
|
| if (settingsService) { |
| const providerResult = await resolveProviderContext( |
| settingsService, |
| model, |
| providerId, |
| '[ValidateIssue]' |
| ); |
| if (providerResult.provider) { |
| claudeCompatibleProvider = providerResult.provider; |
| providerResolvedModel = providerResult.resolvedModel; |
| credentials = providerResult.credentials; |
| logger.info( |
| `Using provider "${providerResult.provider.name}" for model "${model}"` + |
| (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') |
| ); |
| } |
| } |
|
|
| |
| |
| |
| const effectiveModel = claudeCompatibleProvider |
| ? (model as string) |
| : providerResolvedModel || resolveModelString(model as string); |
| logger.info(`Using model: ${effectiveModel}`); |
|
|
| |
| const result = await streamingQuery({ |
| prompt: finalPrompt, |
| model: effectiveModel, |
| cwd: projectPath, |
| systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined, |
| abortController, |
| thinkingLevel: effectiveThinkingLevel, |
| reasoningEffort: effectiveReasoningEffort, |
| readOnly: true, |
| settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined, |
| claudeCompatibleProvider, |
| credentials, |
| outputFormat: useStructuredOutput |
| ? { |
| type: 'json_schema', |
| schema: issueValidationSchema as Record<string, unknown>, |
| } |
| : undefined, |
| onText: (text) => { |
| responseText += text; |
| |
| const progressEvent: IssueValidationEvent = { |
| type: 'issue_validation_progress', |
| issueNumber, |
| content: text, |
| projectPath, |
| }; |
| events.emit('issue-validation:event', progressEvent); |
| }, |
| }); |
|
|
| |
| clearTimeout(timeoutId); |
|
|
| |
| let validationResult: IssueValidationResult | null = null; |
|
|
| if (result.structured_output) { |
| validationResult = result.structured_output as unknown as IssueValidationResult; |
| logger.debug('Received structured output:', validationResult); |
| } else if (responseText) { |
| |
| validationResult = extractJson<IssueValidationResult>(responseText, { logger }); |
| } |
|
|
| |
| if (!validationResult) { |
| logger.error('No validation result received from AI provider'); |
| throw new Error('Validation failed: no valid result received'); |
| } |
|
|
| logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`); |
|
|
| |
| await writeValidation(projectPath, issueNumber, { |
| issueNumber, |
| issueTitle, |
| validatedAt: new Date().toISOString(), |
| model, |
| result: validationResult, |
| }); |
|
|
| |
| const completeEvent: IssueValidationEvent = { |
| type: 'issue_validation_complete', |
| issueNumber, |
| issueTitle, |
| result: validationResult, |
| projectPath, |
| model, |
| }; |
| events.emit('issue-validation:event', completeEvent); |
| } catch (error) { |
| clearTimeout(timeoutId); |
|
|
| const errorMessage = getErrorMessage(error); |
| logError(error, `Issue #${issueNumber} validation failed`); |
|
|
| |
| const errorEvent: IssueValidationEvent = { |
| type: 'issue_validation_error', |
| issueNumber, |
| error: errorMessage, |
| projectPath, |
| }; |
| events.emit('issue-validation:event', errorEvent); |
|
|
| throw error; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function createValidateIssueHandler( |
| events: EventEmitter, |
| settingsService?: SettingsService |
| ) { |
| return async (req: Request, res: Response): Promise<void> => { |
| try { |
| const { |
| projectPath, |
| issueNumber, |
| issueTitle, |
| issueBody, |
| issueLabels, |
| model = 'opus', |
| thinkingLevel, |
| reasoningEffort, |
| providerId, |
| comments: rawComments, |
| linkedPRs: rawLinkedPRs, |
| } = req.body as ValidateIssueRequestBody; |
|
|
| const normalizedProviderId = |
| typeof providerId === 'string' && providerId.trim().length > 0 |
| ? providerId.trim() |
| : undefined; |
|
|
| |
| const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({ |
| author: c.author?.login || 'ghost', |
| createdAt: c.createdAt, |
| body: c.body, |
| })); |
|
|
| |
| const validationLinkedPRs: ValidationLinkedPR[] | undefined = rawLinkedPRs?.map((pr) => ({ |
| number: pr.number, |
| title: pr.title, |
| state: pr.state, |
| })); |
|
|
| logger.info( |
| `[ValidateIssue] Received validation request for issue #${issueNumber}` + |
| (rawComments?.length ? ` with ${rawComments.length} comments` : ' (no comments)') + |
| (rawLinkedPRs?.length ? ` and ${rawLinkedPRs.length} linked PRs` : '') |
| ); |
|
|
| |
| 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; |
| } |
|
|
| if (!issueTitle || typeof issueTitle !== 'string') { |
| res.status(400).json({ success: false, error: 'issueTitle is required' }); |
| return; |
| } |
|
|
| if (typeof issueBody !== 'string') { |
| res.status(400).json({ success: false, error: 'issueBody must be a string' }); |
| return; |
| } |
|
|
| |
| const isValidModel = |
| isClaudeModel(model) || |
| isCursorModel(model) || |
| isCodexModel(model) || |
| isOpencodeModel(model) || |
| !!normalizedProviderId; |
|
|
| if (!isValidModel) { |
| res.status(400).json({ |
| success: false, |
| error: |
| 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias), or provide a valid providerId for custom Claude-compatible models.', |
| }); |
| return; |
| } |
|
|
| logger.info(`Starting async validation for issue #${issueNumber}: ${issueTitle}`); |
|
|
| |
| |
| const abortController = new AbortController(); |
| if (!trySetValidationRunning(projectPath, issueNumber, abortController)) { |
| res.json({ |
| success: false, |
| error: `Validation is already running for issue #${issueNumber}`, |
| }); |
| return; |
| } |
|
|
| |
| runValidation( |
| projectPath, |
| issueNumber, |
| issueTitle, |
| issueBody, |
| issueLabels, |
| model, |
| events, |
| abortController, |
| settingsService, |
| normalizedProviderId, |
| validationComments, |
| validationLinkedPRs, |
| thinkingLevel, |
| reasoningEffort |
| ) |
| .catch(() => { |
| |
| }) |
| .finally(() => { |
| clearValidationStatus(projectPath, issueNumber); |
| }); |
|
|
| |
| res.json({ |
| success: true, |
| message: `Validation started for issue #${issueNumber}`, |
| issueNumber, |
| }); |
| } catch (error) { |
| logError(error, `Issue validation failed`); |
| logger.error('Issue validation error:', error); |
|
|
| if (!res.headersSent) { |
| res.status(500).json({ |
| success: false, |
| error: getErrorMessage(error), |
| }); |
| } |
| } |
| }; |
| } |
|
|