Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/src/routes/github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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 { createListCommentsHandler } from './routes/list-comments.js';
import { createValidateIssueHandler } from './routes/validate-issue.js';
import {
createValidationStatusHandler,
Expand All @@ -27,6 +28,7 @@ export function createGitHubRoutes(
router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler());
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
router.post(
'/validate-issue',
validatePathParams('projectPath'),
Expand Down
212 changes: 212 additions & 0 deletions apps/server/src/routes/github/routes/list-comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* POST /issue-comments endpoint - Fetch comments for a GitHub issue
*/

import { spawn } from 'child_process';
import type { Request, Response } from 'express';
import type { GitHubComment, IssueCommentsResult } from '@automaker/types';
import { execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';

interface ListCommentsRequest {
projectPath: string;
issueNumber: number;
cursor?: string;
}

interface GraphQLComment {
id: string;
author: {
login: string;
avatarUrl?: string;
} | null;
body: string;
createdAt: string;
updatedAt: string;
}

interface GraphQLResponse {
data?: {
repository?: {
issue?: {
comments: {
totalCount: number;
pageInfo: {
hasNextPage: boolean;
endCursor: string | null;
};
nodes: GraphQLComment[];
};
};
};
};
errors?: Array<{ message: string }>;
}

/** Timeout for GitHub API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;

/**
* Validate cursor format (GraphQL cursors are typically base64 strings)
*/
function isValidCursor(cursor: string): boolean {
return /^[A-Za-z0-9+/=]+$/.test(cursor);
}

/**
* Fetch comments for a specific issue using GitHub GraphQL API
*/
async function fetchIssueComments(
projectPath: string,
owner: string,
repo: string,
issueNumber: number,
cursor?: string
): Promise<IssueCommentsResult> {
// Validate cursor format to prevent potential injection
if (cursor && !isValidCursor(cursor)) {
throw new Error('Invalid cursor format');
}

// Use GraphQL variables instead of string interpolation for safety
const query = `
query GetIssueComments($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
comments(first: 50, after: $cursor) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
id
author {
login
avatarUrl
}
body
createdAt
updatedAt
}
}
}
}
}`;

const variables = {
owner,
repo,
issueNumber,
cursor: cursor || null,
};

const requestBody = JSON.stringify({ query, variables });

const response = await new Promise<GraphQLResponse>((resolve, reject) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});

// Add timeout to prevent hanging indefinitely
const timeoutId = setTimeout(() => {
gh.kill();
reject(new Error('GitHub API request timed out'));
}, GITHUB_API_TIMEOUT_MS);

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) => {
clearTimeout(timeoutId);
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();
});

if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
}

const commentsData = response.data?.repository?.issue?.comments;

if (!commentsData) {
throw new Error('Issue not found or no comments data available');
}

const comments: GitHubComment[] = commentsData.nodes.map((node) => ({
id: node.id,
author: {
login: node.author?.login || 'ghost',
avatarUrl: node.author?.avatarUrl,
},
body: node.body,
createdAt: node.createdAt,
updatedAt: node.updatedAt,
}));

return {
comments,
totalCount: commentsData.totalCount,
hasNextPage: commentsData.pageInfo.hasNextPage,
endCursor: commentsData.pageInfo.endCursor || undefined,
};
}

export function createListCommentsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, issueNumber, cursor } = req.body as ListCommentsRequest;

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;
}

// First check if this is a GitHub repo and get owner/repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}

const result = await fetchIssueComments(
projectPath,
remoteStatus.owner,
remoteStatus.repo,
issueNumber,
cursor
);

res.json({
success: true,
...result,
});
} catch (error) {
logError(error, `Fetch comments for issue failed`);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
66 changes: 52 additions & 14 deletions apps/server/src/routes/github/routes/validate-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@
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 type {
IssueValidationResult,
IssueValidationEvent,
AgentModel,
GitHubComment,
LinkedPRInfo,
} from '@automaker/types';
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
import { writeValidation } from '../../../lib/validation-storage.js';
import {
issueValidationSchema,
ISSUE_VALIDATION_SYSTEM_PROMPT,
buildValidationPrompt,
ValidationComment,
ValidationLinkedPR,
} from './validation-schema.js';
import {
trySetValidationRunning,
Expand All @@ -40,6 +48,10 @@ interface ValidateIssueRequestBody {
issueLabels?: string[];
/** Model to use for validation (opus, sonnet, haiku) */
model?: AgentModel;
/** Comments to include in validation analysis */
comments?: GitHubComment[];
/** Linked pull requests for this issue */
linkedPRs?: LinkedPRInfo[];
}

/**
Expand All @@ -57,7 +69,9 @@ async function runValidation(
model: AgentModel,
events: EventEmitter,
abortController: AbortController,
settingsService?: SettingsService
settingsService?: SettingsService,
comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[]
): Promise<void> {
// Emit start event
const startEvent: IssueValidationEvent = {
Expand All @@ -76,8 +90,15 @@ async function runValidation(
}, VALIDATION_TIMEOUT_MS);

try {
// Build the prompt
const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels);
// Build the prompt (include comments and linked PRs if provided)
const prompt = buildValidationPrompt(
issueNumber,
issueTitle,
issueBody,
issueLabels,
comments,
linkedPRs
);

// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
Expand All @@ -102,16 +123,12 @@ async function runValidation(
// 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
// Emit progress events for assistant text
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,
Expand All @@ -128,7 +145,6 @@ async function runValidation(
const resultMsg = msg as { structured_output?: IssueValidationResult };
if (resultMsg.structured_output) {
validationResult = resultMsg.structured_output;
logger.debug('Received structured output:', validationResult);
}
}

Expand All @@ -148,7 +164,6 @@ async function runValidation(
// 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');
}

Expand Down Expand Up @@ -214,8 +229,30 @@ export function createValidateIssueHandler(
issueBody,
issueLabels,
model = 'opus',
comments: rawComments,
linkedPRs: rawLinkedPRs,
} = req.body as ValidateIssueRequestBody;

// Transform GitHubComment[] to ValidationComment[] if provided
const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({
author: c.author?.login || 'ghost',
createdAt: c.createdAt,
body: c.body,
}));

// Transform LinkedPRInfo[] to ValidationLinkedPR[] if provided
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` : '')
);

// Validate required fields
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
Expand Down Expand Up @@ -271,11 +308,12 @@ export function createValidateIssueHandler(
model,
events,
abortController,
settingsService
settingsService,
validationComments,
validationLinkedPRs
)
.catch((error) => {
.catch(() => {
// Error is already handled inside runValidation (event emitted)
logger.debug('Validation error caught in background handler:', error);
})
.finally(() => {
clearValidationStatus(projectPath, issueNumber);
Expand Down
Loading