mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
Merge pull request #250 from AutoMaker-Org/feat/convert-issues-to-task
feat: abbility to analyze github issues with ai with confidence / task creation
This commit is contained in:
@@ -48,6 +48,7 @@ import { createClaudeRoutes } from './routes/claude/index.js';
|
||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||
import { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -123,6 +124,15 @@ const claudeUsageService = new ClaudeUsageService();
|
||||
console.log('[Server] Agent service initialized');
|
||||
})();
|
||||
|
||||
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
||||
const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
setInterval(() => {
|
||||
const cleaned = cleanupStaleValidations();
|
||||
if (cleaned > 0) {
|
||||
console.log(`[Server] Cleaned up ${cleaned} stale validation entries`);
|
||||
}
|
||||
}, VALIDATION_CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Mount API routes - health is unauthenticated for monitoring
|
||||
app.use('/api/health', createHealthRoutes());
|
||||
|
||||
@@ -147,7 +157,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
|
||||
|
||||
181
apps/server/src/lib/validation-storage.ts
Normal file
181
apps/server/src/lib/validation-storage.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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 validationsDir = getValidationsDir(projectPath);
|
||||
|
||||
try {
|
||||
const dirs = await secureFs.readdir(validationsDir, { withFileTypes: true });
|
||||
|
||||
// Read all validation files in parallel for better performance
|
||||
const promises = dirs
|
||||
.filter((dir) => dir.isDirectory())
|
||||
.map((dir) => {
|
||||
const issueNumber = parseInt(dir.name, 10);
|
||||
if (!isNaN(issueNumber)) {
|
||||
return readValidation(projectPath, issueNumber);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const validations = results.filter((v): v is StoredValidation => v !== null);
|
||||
|
||||
// Sort by issue number
|
||||
validations.sort((a, b) => a.issueNumber - b.issueNumber);
|
||||
|
||||
return validations;
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a validation as viewed by the user
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param issueNumber - GitHub issue number
|
||||
* @returns true if validation was marked as viewed, false if not found
|
||||
*/
|
||||
export async function markValidationViewed(
|
||||
projectPath: string,
|
||||
issueNumber: number
|
||||
): Promise<boolean> {
|
||||
const validation = await readValidation(projectPath, issueNumber);
|
||||
if (!validation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
validation.viewedAt = new Date().toISOString();
|
||||
await writeValidation(projectPath, issueNumber, validation);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of unviewed, non-stale validations for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Number of unviewed validations
|
||||
*/
|
||||
export async function getUnviewedValidationsCount(projectPath: string): Promise<number> {
|
||||
const validations = await getAllValidations(projectPath);
|
||||
return validations.filter((v) => !v.viewedAt && !isValidationStale(v)).length;
|
||||
}
|
||||
@@ -3,16 +3,50 @@
|
||||
*/
|
||||
|
||||
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,
|
||||
createMarkViewedHandler,
|
||||
} from './routes/validation-endpoints.js';
|
||||
|
||||
export function createGitHubRoutes(): Router {
|
||||
export function createGitHubRoutes(events: EventEmitter): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/check-remote', createCheckGitHubRemoteHandler());
|
||||
router.post('/issues', createListIssuesHandler());
|
||||
router.post('/prs', createListPRsHandler());
|
||||
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(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()
|
||||
);
|
||||
router.post(
|
||||
'/validation-mark-viewed',
|
||||
validatePathParams('projectPath'),
|
||||
createMarkViewedHandler(events)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* POST /list-issues endpoint - List GitHub issues for a project
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import type { Request, Response } from 'express';
|
||||
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
|
||||
import { checkGitHubRemote } from './check-github-remote.js';
|
||||
@@ -13,6 +14,19 @@ export interface GitHubLabel {
|
||||
|
||||
export interface GitHubAuthor {
|
||||
login: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface GitHubAssignee {
|
||||
login: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface LinkedPullRequest {
|
||||
number: number;
|
||||
title: string;
|
||||
state: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GitHubIssue {
|
||||
@@ -24,6 +38,8 @@ export interface GitHubIssue {
|
||||
labels: GitHubLabel[];
|
||||
url: string;
|
||||
body: string;
|
||||
assignees: GitHubAssignee[];
|
||||
linkedPRs?: LinkedPullRequest[];
|
||||
}
|
||||
|
||||
export interface ListIssuesResult {
|
||||
@@ -33,6 +49,146 @@ export interface ListIssuesResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch linked PRs for a list of issues using GitHub GraphQL API
|
||||
*/
|
||||
async function fetchLinkedPRs(
|
||||
projectPath: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumbers: number[]
|
||||
): Promise<Map<number, LinkedPullRequest[]>> {
|
||||
const linkedPRsMap = new Map<number, LinkedPullRequest[]>();
|
||||
|
||||
if (issueNumbers.length === 0) {
|
||||
return linkedPRsMap;
|
||||
}
|
||||
|
||||
// Build GraphQL query for batch fetching linked PRs
|
||||
// We fetch up to 20 issues at a time to avoid query limits
|
||||
const batchSize = 20;
|
||||
for (let i = 0; i < issueNumbers.length; i += batchSize) {
|
||||
const batch = issueNumbers.slice(i, i + batchSize);
|
||||
|
||||
const issueQueries = batch
|
||||
.map(
|
||||
(num, idx) => `
|
||||
issue${idx}: issue(number: ${num}) {
|
||||
number
|
||||
timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) {
|
||||
nodes {
|
||||
... on CrossReferencedEvent {
|
||||
source {
|
||||
... on PullRequest {
|
||||
number
|
||||
title
|
||||
state
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
... on ConnectedEvent {
|
||||
subject {
|
||||
... on PullRequest {
|
||||
number
|
||||
title
|
||||
state
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const query = `{
|
||||
repository(owner: "${owner}", name: "${repo}") {
|
||||
${issueQueries}
|
||||
}
|
||||
}`;
|
||||
|
||||
try {
|
||||
// Use spawn with stdin to avoid shell injection vulnerabilities
|
||||
// --input - reads the JSON request body from stdin
|
||||
const requestBody = JSON.stringify({ query });
|
||||
const response = await new Promise<Record<string, unknown>>((resolve, reject) => {
|
||||
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
|
||||
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
|
||||
|
||||
gh.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
gh.stdin.write(requestBody);
|
||||
gh.stdin.end();
|
||||
});
|
||||
|
||||
const repoData = (response?.data as Record<string, unknown>)?.repository as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
|
||||
if (repoData) {
|
||||
batch.forEach((issueNum, idx) => {
|
||||
const issueData = repoData[`issue${idx}`] as {
|
||||
timelineItems?: {
|
||||
nodes?: Array<{
|
||||
source?: { number?: number; title?: string; state?: string; url?: string };
|
||||
subject?: { number?: number; title?: string; state?: string; url?: string };
|
||||
}>;
|
||||
};
|
||||
} | null;
|
||||
if (issueData?.timelineItems?.nodes) {
|
||||
const linkedPRs: LinkedPullRequest[] = [];
|
||||
const seenPRs = new Set<number>();
|
||||
|
||||
for (const node of issueData.timelineItems.nodes) {
|
||||
const pr = node?.source || node?.subject;
|
||||
if (pr?.number && !seenPRs.has(pr.number)) {
|
||||
seenPRs.add(pr.number);
|
||||
linkedPRs.push({
|
||||
number: pr.number,
|
||||
title: pr.title || '',
|
||||
state: (pr.state || '').toLowerCase(),
|
||||
url: pr.url || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (linkedPRs.length > 0) {
|
||||
linkedPRsMap.set(issueNum, linkedPRs);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// If GraphQL fails, continue without linked PRs
|
||||
console.warn(
|
||||
'Failed to fetch linked PRs via GraphQL:',
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return linkedPRsMap;
|
||||
}
|
||||
|
||||
export function createListIssuesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -53,17 +209,17 @@ export function createListIssuesHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch open and closed issues in parallel
|
||||
// Fetch open and closed issues in parallel (now including assignees)
|
||||
const [openResult, closedResult] = await Promise.all([
|
||||
execAsync(
|
||||
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100',
|
||||
'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100',
|
||||
{
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
}
|
||||
),
|
||||
execAsync(
|
||||
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50',
|
||||
'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50',
|
||||
{
|
||||
cwd: projectPath,
|
||||
env: execEnv,
|
||||
@@ -77,6 +233,24 @@ export function createListIssuesHandler() {
|
||||
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
|
||||
const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]');
|
||||
|
||||
// Fetch linked PRs for open issues (more relevant for active work)
|
||||
if (remoteStatus.owner && remoteStatus.repo && openIssues.length > 0) {
|
||||
const linkedPRsMap = await fetchLinkedPRs(
|
||||
projectPath,
|
||||
remoteStatus.owner,
|
||||
remoteStatus.repo,
|
||||
openIssues.map((i) => i.number)
|
||||
);
|
||||
|
||||
// Attach linked PRs to issues
|
||||
for (const issue of openIssues) {
|
||||
const linkedPRs = linkedPRsMap.get(issue.number);
|
||||
if (linkedPRs) {
|
||||
issue.linkedPRs = linkedPRs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
openIssues,
|
||||
|
||||
287
apps/server/src/routes/github/routes/validate-issue.ts
Normal file
287
apps/server/src/routes/github/routes/validate-issue.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 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 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 {
|
||||
trySetValidationRunning,
|
||||
clearValidationStatus,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
logger,
|
||||
} from './validation-common.js';
|
||||
|
||||
/** Valid model values for validation */
|
||||
const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const;
|
||||
|
||||
/**
|
||||
* Request body for issue validation
|
||||
*/
|
||||
interface ValidateIssueRequestBody {
|
||||
projectPath: string;
|
||||
issueNumber: number;
|
||||
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,
|
||||
model,
|
||||
};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the handler for validating GitHub issues against the codebase.
|
||||
*
|
||||
* Uses Claude SDK with:
|
||||
* - 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(events: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
issueNumber,
|
||||
issueTitle,
|
||||
issueBody,
|
||||
issueLabels,
|
||||
model = 'opus',
|
||||
} = req.body as ValidateIssueRequestBody;
|
||||
|
||||
// Validate required fields
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
logger.info(`Starting async validation for issue #${issueNumber}: ${issueTitle}`);
|
||||
|
||||
// Create abort controller and atomically try to claim validation slot
|
||||
// This prevents TOCTOU race conditions
|
||||
const abortController = new AbortController();
|
||||
if (!trySetValidationRunning(projectPath, issueNumber, abortController)) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: `Validation is already running for issue #${issueNumber}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Return immediately
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
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 };
|
||||
236
apps/server/src/routes/github/routes/validation-endpoints.ts
Normal file
236
apps/server/src/routes/github/routes/validation-endpoints.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Additional validation endpoints for status, stop, and retrieving stored validations
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IssueValidationEvent } from '@automaker/types';
|
||||
import {
|
||||
isValidationRunning,
|
||||
getValidationStatus,
|
||||
getRunningValidations,
|
||||
abortValidation,
|
||||
getErrorMessage,
|
||||
logError,
|
||||
logger,
|
||||
} from './validation-common.js';
|
||||
import {
|
||||
readValidation,
|
||||
getAllValidations,
|
||||
getValidationWithFreshness,
|
||||
deleteValidation,
|
||||
markValidationViewed,
|
||||
} 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /validation-mark-viewed - Mark a validation as viewed by the user
|
||||
*/
|
||||
export function createMarkViewedHandler(events: EventEmitter) {
|
||||
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 success = await markValidationViewed(projectPath, issueNumber);
|
||||
|
||||
if (success) {
|
||||
// Emit event so UI can update the unviewed count
|
||||
const viewedEvent: IssueValidationEvent = {
|
||||
type: 'issue_validation_viewed',
|
||||
issueNumber,
|
||||
projectPath,
|
||||
};
|
||||
events.emit('issue-validation:event', viewedEvent);
|
||||
}
|
||||
|
||||
res.json({ success });
|
||||
} catch (error) {
|
||||
logError(error, 'Mark validation viewed failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
138
apps/server/src/routes/github/routes/validation-schema.ts
Normal file
138
apps/server/src/routes/github/routes/validation-schema.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Issue Validation Schema and System Prompt
|
||||
*
|
||||
* Defines the JSON schema for Claude's structured output and
|
||||
* the system prompt that guides the validation process.
|
||||
*/
|
||||
|
||||
/**
|
||||
* JSON Schema for issue validation structured output.
|
||||
* Used with Claude SDK's outputFormat option to ensure reliable parsing.
|
||||
*/
|
||||
export const issueValidationSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
verdict: {
|
||||
type: 'string',
|
||||
enum: ['valid', 'invalid', 'needs_clarification'],
|
||||
description: 'The validation verdict for the issue',
|
||||
},
|
||||
confidence: {
|
||||
type: 'string',
|
||||
enum: ['high', 'medium', 'low'],
|
||||
description: 'How confident the AI is in its assessment',
|
||||
},
|
||||
reasoning: {
|
||||
type: 'string',
|
||||
description: 'Detailed explanation of the verdict',
|
||||
},
|
||||
bugConfirmed: {
|
||||
type: 'boolean',
|
||||
description: 'For bug reports: whether the bug was confirmed in the codebase',
|
||||
},
|
||||
relatedFiles: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Files related to the issue found during analysis',
|
||||
},
|
||||
suggestedFix: {
|
||||
type: 'string',
|
||||
description: 'Suggested approach to fix or implement the issue',
|
||||
},
|
||||
missingInfo: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Information needed when verdict is needs_clarification',
|
||||
},
|
||||
estimatedComplexity: {
|
||||
type: 'string',
|
||||
enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'],
|
||||
description: 'Estimated effort to address the issue',
|
||||
},
|
||||
},
|
||||
required: ['verdict', 'confidence', 'reasoning'],
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* System prompt that guides Claude in validating GitHub issues.
|
||||
* Instructs the model to use read-only tools to analyze the codebase.
|
||||
*/
|
||||
export const ISSUE_VALIDATION_SYSTEM_PROMPT = `You are an expert code analyst validating GitHub issues against a codebase.
|
||||
|
||||
Your task is to analyze a GitHub issue and determine if it's valid by scanning the codebase.
|
||||
|
||||
## Validation Process
|
||||
|
||||
1. **Read the issue carefully** - Understand what is being reported or requested
|
||||
2. **Search the codebase** - Use Glob to find relevant files by pattern, Grep to search for keywords
|
||||
3. **Examine the code** - Use Read to look at the actual implementation in relevant files
|
||||
4. **Form your verdict** - Based on your analysis, determine if the issue is valid
|
||||
|
||||
## Verdicts
|
||||
|
||||
- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable.
|
||||
|
||||
- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior.
|
||||
|
||||
- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field.
|
||||
|
||||
## For Bug Reports, Check:
|
||||
- Do the referenced files/components exist?
|
||||
- Does the code match what the issue describes?
|
||||
- Is the described behavior actually a bug or expected?
|
||||
- Can you locate the code that would cause the reported issue?
|
||||
|
||||
## For Feature Requests, Check:
|
||||
- Does the feature already exist?
|
||||
- Is the implementation location clear?
|
||||
- Is the request technically feasible given the codebase structure?
|
||||
|
||||
## Response Guidelines
|
||||
|
||||
- **Always include relatedFiles** when you find relevant code
|
||||
- **Set bugConfirmed to true** only if you can definitively confirm a bug exists in the code
|
||||
- **Provide a suggestedFix** when you have a clear idea of how to address the issue
|
||||
- **Use missingInfo** when the verdict is needs_clarification to list what's needed
|
||||
- **Set estimatedComplexity** to help prioritize:
|
||||
- trivial: Simple text changes, one-line fixes
|
||||
- simple: Small changes to one file
|
||||
- moderate: Changes to multiple files or moderate logic changes
|
||||
- complex: Significant refactoring or new feature implementation
|
||||
- very_complex: Major architectural changes or cross-cutting concerns
|
||||
|
||||
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
|
||||
|
||||
/**
|
||||
* Build the user prompt for issue validation.
|
||||
*
|
||||
* Creates a structured prompt that includes the issue details for Claude
|
||||
* to analyze against the codebase.
|
||||
*
|
||||
* @param issueNumber - The GitHub issue number
|
||||
* @param issueTitle - The issue title
|
||||
* @param issueBody - The issue body/description
|
||||
* @param issueLabels - Optional array of label names
|
||||
* @returns Formatted prompt string for the validation request
|
||||
*/
|
||||
export function buildValidationPrompt(
|
||||
issueNumber: number,
|
||||
issueTitle: string,
|
||||
issueBody: string,
|
||||
issueLabels?: string[]
|
||||
): string {
|
||||
const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : '';
|
||||
|
||||
return `Please validate the following GitHub issue by analyzing the codebase:
|
||||
|
||||
## Issue #${issueNumber}: ${issueTitle}
|
||||
${labelsSection}
|
||||
|
||||
### Description
|
||||
|
||||
${issueBody || '(No description provided)'}
|
||||
|
||||
---
|
||||
|
||||
Scan the codebase to verify this issue. Look for the files, components, or functionality mentioned. Determine if this issue is valid, invalid, or needs clarification.`;
|
||||
}
|
||||
Reference in New Issue
Block a user