Merge pull request #308 from AutoMaker-Org/feat/github-issue-comments

feat: add GitHub issue comments display and AI validation integration
This commit is contained in:
Shirone
2025-12-28 23:00:06 +01:00
committed by GitHub
17 changed files with 898 additions and 44 deletions

View File

@@ -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,
@@ -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'),

View File

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

View File

@@ -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,
@@ -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[];
}
/**
@@ -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 = {
@@ -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(
@@ -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,
@@ -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);
}
}
@@ -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');
}
@@ -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' });
@@ -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);

View File

@@ -49,6 +49,34 @@ export const issueValidationSchema = {
enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'],
description: 'Estimated effort to address the issue',
},
prAnalysis: {
type: 'object',
properties: {
hasOpenPR: {
type: 'boolean',
description: 'Whether there is an open PR linked to this issue',
},
prFixesIssue: {
type: 'boolean',
description: 'Whether the PR appears to fix the issue based on the diff',
},
prNumber: {
type: 'number',
description: 'The PR number that was analyzed',
},
prSummary: {
type: 'string',
description: 'Brief summary of what the PR changes',
},
recommendation: {
type: 'string',
enum: ['wait_for_merge', 'pr_needs_work', 'no_pr'],
description:
'Recommendation: wait for PR to merge, PR needs more work, or no relevant PR',
},
},
description: 'Analysis of linked pull requests if any exist',
},
},
required: ['verdict', 'confidence', 'reasoning'],
additionalProperties: false,
@@ -67,7 +95,8 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
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
4. **Check linked PRs** - If there are linked pull requests, use \`gh pr diff <PR_NUMBER>\` to review the changes
5. **Form your verdict** - Based on your analysis, determine if the issue is valid
## Verdicts
@@ -88,12 +117,32 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
- Is the implementation location clear?
- Is the request technically feasible given the codebase structure?
## Analyzing Linked Pull Requests
When an issue has linked PRs (especially open ones), you MUST analyze them:
1. **Run \`gh pr diff <PR_NUMBER>\`** to see what changes the PR makes
2. **Run \`gh pr view <PR_NUMBER>\`** to see PR description and status
3. **Evaluate if the PR fixes the issue** - Does the diff address the reported problem?
4. **Provide a recommendation**:
- \`wait_for_merge\`: The PR appears to fix the issue correctly. No additional work needed - just wait for it to be merged.
- \`pr_needs_work\`: The PR attempts to fix the issue but is incomplete or has problems.
- \`no_pr\`: No relevant PR exists for this issue.
5. **Include prAnalysis in your response** with:
- hasOpenPR: true/false
- prFixesIssue: true/false (based on diff analysis)
- prNumber: the PR number you analyzed
- prSummary: brief description of what the PR changes
- recommendation: one of the above values
## 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
- **Include prAnalysis** when there are linked PRs - this is critical for avoiding duplicate work
- **Set estimatedComplexity** to help prioritize:
- trivial: Simple text changes, one-line fixes
- simple: Small changes to one file
@@ -103,6 +152,24 @@ Your task is to analyze a GitHub issue and determine if it's valid by scanning t
Be thorough in your analysis but focus on files that are directly relevant to the issue.`;
/**
* Comment data structure for validation prompt
*/
export interface ValidationComment {
author: string;
createdAt: string;
body: string;
}
/**
* Linked PR data structure for validation prompt
*/
export interface ValidationLinkedPR {
number: number;
title: string;
state: string;
}
/**
* Build the user prompt for issue validation.
*
@@ -113,26 +180,60 @@ Be thorough in your analysis but focus on files that are directly relevant to th
* @param issueTitle - The issue title
* @param issueBody - The issue body/description
* @param issueLabels - Optional array of label names
* @param comments - Optional array of comments to include in analysis
* @param linkedPRs - Optional array of linked pull requests
* @returns Formatted prompt string for the validation request
*/
export function buildValidationPrompt(
issueNumber: number,
issueTitle: string,
issueBody: string,
issueLabels?: string[]
issueLabels?: string[],
comments?: ValidationComment[],
linkedPRs?: ValidationLinkedPR[]
): string {
const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : '';
let linkedPRsSection = '';
if (linkedPRs && linkedPRs.length > 0) {
const prsText = linkedPRs
.map((pr) => `- PR #${pr.number} (${pr.state}): ${pr.title}`)
.join('\n');
linkedPRsSection = `\n\n### Linked Pull Requests\n\n${prsText}`;
}
let commentsSection = '';
if (comments && comments.length > 0) {
// Limit to most recent 10 comments to control prompt size
const recentComments = comments.slice(-10);
const commentsText = recentComments
.map(
(c) => `**${c.author}** (${new Date(c.createdAt).toISOString().slice(0, 10)}):\n${c.body}`
)
.join('\n\n---\n\n');
commentsSection = `\n\n### Comments (${comments.length} total${comments.length > 10 ? ', showing last 10' : ''})\n\n${commentsText}`;
}
const hasWorkInProgress =
linkedPRs && linkedPRs.some((pr) => pr.state === 'open' || pr.state === 'OPEN');
const workInProgressNote = hasWorkInProgress
? '\n\n**Note:** This issue has an open pull request linked. Consider that someone may already be working on a fix.'
: '';
return `Please validate the following GitHub issue by analyzing the codebase:
## Issue #${issueNumber}: ${issueTitle}
${labelsSection}
${linkedPRsSection}
### Description
${issueBody || '(No description provided)'}
${commentsSection}
${workInProgressNote}
---
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.`;
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.${comments && comments.length > 0 ? ' Consider the context provided in the comments as well.' : ''}${hasWorkInProgress ? ' Also note in your analysis if there is already work in progress on this issue.' : ''}`;
}

View File

@@ -11,12 +11,15 @@ import { useGithubIssues, useIssueValidation } from './github-issues-view/hooks'
import { IssueRow, IssueDetailPanel, IssuesListHeader } from './github-issues-view/components';
import { ValidationDialog } from './github-issues-view/dialogs';
import { formatDate, getFeaturePriority } from './github-issues-view/utils';
import type { ValidateIssueOptions } from './github-issues-view/types';
export function GitHubIssuesView() {
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(null);
const [showValidationDialog, setShowValidationDialog] = useState(false);
const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false);
const [pendingRevalidateOptions, setPendingRevalidateOptions] =
useState<ValidateIssueOptions | null>(null);
const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } =
useAppStore();
@@ -203,7 +206,10 @@ export function GitHubIssuesView() {
onViewCachedValidation={handleViewCachedValidation}
onOpenInGitHub={handleOpenInGitHub}
onClose={() => setSelectedIssue(null)}
onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)}
onShowRevalidateConfirm={(options) => {
setPendingRevalidateOptions(options);
setShowRevalidateConfirm(true);
}}
formatDate={formatDate}
/>
)}
@@ -220,15 +226,24 @@ export function GitHubIssuesView() {
{/* Revalidate Confirmation Dialog */}
<ConfirmDialog
open={showRevalidateConfirm}
onOpenChange={setShowRevalidateConfirm}
onOpenChange={(open) => {
setShowRevalidateConfirm(open);
if (!open) {
setPendingRevalidateOptions(null);
}
}}
title="Re-validate Issue"
description={`Are you sure you want to re-validate issue #${selectedIssue?.number}? This will run a new AI analysis and replace the existing validation result.`}
icon={RefreshCw}
iconClassName="text-primary"
confirmText="Re-validate"
onConfirm={() => {
if (selectedIssue) {
handleValidateIssue(selectedIssue, { forceRevalidate: true });
if (selectedIssue && pendingRevalidateOptions) {
console.log('[GitHubIssuesView] Revalidating with options:', {
commentsCount: pendingRevalidateOptions.comments?.length ?? 0,
linkedPRsCount: pendingRevalidateOptions.linkedPRs?.length ?? 0,
});
handleValidateIssue(selectedIssue, pendingRevalidateOptions);
}
}}
/>

View File

@@ -0,0 +1,40 @@
import { User } from 'lucide-react';
import { Markdown } from '@/components/ui/markdown';
import type { GitHubComment } from '@/lib/electron';
import { formatDate } from '../utils';
interface CommentItemProps {
comment: GitHubComment;
}
export function CommentItem({ comment }: CommentItemProps) {
return (
<div className="p-3 rounded-lg bg-background border border-border">
{/* Comment Header */}
<div className="flex items-center gap-2 mb-2">
{comment.author.avatarUrl ? (
<img
src={comment.author.avatarUrl}
alt={comment.author.login}
className="h-6 w-6 rounded-full"
/>
) : (
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center">
<User className="h-3 w-3 text-muted-foreground" />
</div>
)}
<span className="text-sm font-medium">{comment.author.login}</span>
<span className="text-xs text-muted-foreground">
commented {formatDate(comment.createdAt)}
</span>
</div>
{/* Comment Body */}
{comment.body ? (
<Markdown className="text-sm">{comment.body}</Markdown>
) : (
<p className="text-sm text-muted-foreground italic">No content</p>
)}
</div>
);
}

View File

@@ -1,3 +1,4 @@
export { IssueRow } from './issue-row';
export { IssueDetailPanel } from './issue-detail-panel';
export { IssuesListHeader } from './issues-list-header';
export { CommentItem } from './comment-item';

View File

@@ -10,12 +10,19 @@ import {
GitPullRequest,
User,
RefreshCw,
MessageSquare,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Markdown } from '@/components/ui/markdown';
import { cn } from '@/lib/utils';
import type { IssueDetailPanelProps } from '../types';
import { isValidationStale } from '../utils';
import { useIssueComments } from '../hooks';
import { CommentItem } from './comment-item';
export function IssueDetailPanel({
issue,
@@ -32,6 +39,32 @@ export function IssueDetailPanel({
const cached = cachedValidations.get(issue.number);
const isStale = cached ? isValidationStale(cached.validatedAt) : false;
// Comments state
const [commentsExpanded, setCommentsExpanded] = useState(true);
const [includeCommentsInAnalysis, setIncludeCommentsInAnalysis] = useState(true);
const {
comments,
totalCount,
loading: commentsLoading,
loadingMore,
hasNextPage,
error: commentsError,
loadMore,
} = useIssueComments(issue.number);
// Helper to get validation options with comments and linked PRs
const getValidationOptions = (forceRevalidate = false) => {
return {
forceRevalidate,
comments: includeCommentsInAnalysis && comments.length > 0 ? comments : undefined,
linkedPRs: issue.linkedPRs?.map((pr) => ({
number: pr.number,
title: pr.title,
state: pr.state,
})),
};
};
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
@@ -67,7 +100,7 @@ export function IssueDetailPanel({
<Button
variant="ghost"
size="sm"
onClick={onShowRevalidateConfirm}
onClick={() => onShowRevalidateConfirm(getValidationOptions(true))}
title="Re-validate"
>
<RefreshCw className="h-4 w-4" />
@@ -86,7 +119,7 @@ export function IssueDetailPanel({
<Button
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, { forceRevalidate: true })}
onClick={() => onValidateIssue(issue, getValidationOptions(true))}
>
<Wand2 className="h-4 w-4 mr-1" />
Re-validate
@@ -96,7 +129,11 @@ export function IssueDetailPanel({
}
return (
<Button variant="default" size="sm" onClick={() => onValidateIssue(issue)}>
<Button
variant="default"
size="sm"
onClick={() => onValidateIssue(issue, getValidationOptions())}
>
<Wand2 className="h-4 w-4 mr-1" />
Validate with AI
</Button>
@@ -226,6 +263,74 @@ export function IssueDetailPanel({
<p className="text-sm text-muted-foreground italic">No description provided.</p>
)}
{/* Comments Section */}
<div className="mt-6 p-3 rounded-lg bg-muted/30 border border-border">
<div className="flex items-center justify-between">
<button
className="flex items-center gap-2 text-left"
onClick={() => setCommentsExpanded(!commentsExpanded)}
>
<MessageSquare className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">
Comments {totalCount > 0 && `(${totalCount})`}
</span>
{commentsLoading && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
{commentsExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{comments.length > 0 && (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={includeCommentsInAnalysis}
onCheckedChange={setIncludeCommentsInAnalysis}
/>
Include in AI analysis
</label>
)}
</div>
{commentsExpanded && (
<div className="mt-3">
{commentsError ? (
<p className="text-sm text-red-500">{commentsError}</p>
) : comments.length === 0 && !commentsLoading ? (
<p className="text-sm text-muted-foreground italic">No comments yet.</p>
) : (
<div className="space-y-3">
{comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} />
))}
{/* Load More Button */}
{hasNextPage && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading...
</>
) : (
'Load More Comments'
)}
</Button>
)}
</div>
)}
</div>
)}
</div>
{/* Open in GitHub CTA */}
<div className="mt-8 p-4 rounded-lg bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground mb-3">

View File

@@ -16,6 +16,9 @@ import {
Lightbulb,
AlertTriangle,
Plus,
GitPullRequest,
Clock,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type {
@@ -149,6 +152,77 @@ export function ValidationDialog({
</div>
)}
{/* PR Analysis Section - Show AI's analysis of linked PRs */}
{validationResult.prAnalysis && validationResult.prAnalysis.hasOpenPR && (
<div
className={cn(
'p-3 rounded-lg border',
validationResult.prAnalysis.recommendation === 'wait_for_merge'
? 'bg-green-500/10 border-green-500/20'
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
? 'bg-yellow-500/10 border-yellow-500/20'
: 'bg-purple-500/10 border-purple-500/20'
)}
>
<div className="flex items-start gap-2">
{validationResult.prAnalysis.recommendation === 'wait_for_merge' ? (
<Clock className="h-5 w-5 text-green-500 shrink-0 mt-0.5" />
) : validationResult.prAnalysis.recommendation === 'pr_needs_work' ? (
<Wrench className="h-5 w-5 text-yellow-500 shrink-0 mt-0.5" />
) : (
<GitPullRequest className="h-5 w-5 text-purple-500 shrink-0 mt-0.5" />
)}
<div className="flex-1">
<span
className={cn(
'text-sm font-medium',
validationResult.prAnalysis.recommendation === 'wait_for_merge'
? 'text-green-500'
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
? 'text-yellow-500'
: 'text-purple-500'
)}
>
{validationResult.prAnalysis.recommendation === 'wait_for_merge'
? 'Fix Ready - Wait for Merge'
: validationResult.prAnalysis.recommendation === 'pr_needs_work'
? 'PR Needs Work'
: 'Work in Progress'}
</span>
{validationResult.prAnalysis.prNumber && (
<p className="text-xs text-muted-foreground mt-0.5">
PR #{validationResult.prAnalysis.prNumber}
{validationResult.prAnalysis.prFixesIssue && ' appears to fix this issue'}
</p>
)}
{validationResult.prAnalysis.prSummary && (
<p className="text-xs text-muted-foreground mt-1">
{validationResult.prAnalysis.prSummary}
</p>
)}
</div>
</div>
</div>
)}
{/* Fallback Work in Progress Badge - Show when there's an open PR but no AI analysis */}
{!validationResult.prAnalysis?.hasOpenPR &&
issue.linkedPRs?.some((pr) => pr.state === 'open' || pr.state === 'OPEN') && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-purple-500/10 border border-purple-500/20">
<GitPullRequest className="h-5 w-5 text-purple-500 shrink-0" />
<div className="flex-1">
<span className="text-sm font-medium text-purple-500">Work in Progress</span>
<p className="text-xs text-muted-foreground mt-0.5">
{issue.linkedPRs
.filter((pr) => pr.state === 'open' || pr.state === 'OPEN')
.map((pr) => `PR #${pr.number}`)
.join(', ')}{' '}
is open for this issue
</p>
</div>
</div>
)}
{/* Reasoning */}
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2">
@@ -218,12 +292,14 @@ export function ValidationDialog({
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Close
</Button>
{validationResult?.verdict === 'valid' && onConvertToTask && (
<Button onClick={handleConvertToTask}>
<Plus className="h-4 w-4 mr-2" />
Convert to Task
</Button>
)}
{validationResult?.verdict === 'valid' &&
onConvertToTask &&
validationResult?.prAnalysis?.recommendation !== 'wait_for_merge' && (
<Button onClick={handleConvertToTask}>
<Plus className="h-4 w-4 mr-2" />
Convert to Task
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,2 +1,3 @@
export { useGithubIssues } from './use-github-issues';
export { useIssueValidation } from './use-issue-validation';
export { useIssueComments } from './use-issue-comments';

View File

@@ -0,0 +1,134 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { getElectronAPI, GitHubComment } from '@/lib/electron';
import { useAppStore } from '@/store/app-store';
interface UseIssueCommentsResult {
comments: GitHubComment[];
totalCount: number;
loading: boolean;
loadingMore: boolean;
hasNextPage: boolean;
error: string | null;
loadMore: () => void;
refresh: () => void;
}
export function useIssueComments(issueNumber: number | null): UseIssueCommentsResult {
const { currentProject } = useAppStore();
const [comments, setComments] = useState<GitHubComment[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasNextPage, setHasNextPage] = useState(false);
const [endCursor, setEndCursor] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
const fetchComments = useCallback(
async (cursor?: string) => {
if (!currentProject?.path || !issueNumber) {
return;
}
const isLoadingMore = !!cursor;
try {
if (isMountedRef.current) {
setError(null);
if (isLoadingMore) {
setLoadingMore(true);
} else {
setLoading(true);
}
}
const api = getElectronAPI();
if (api.github) {
const result = await api.github.getIssueComments(
currentProject.path,
issueNumber,
cursor
);
if (isMountedRef.current) {
if (result.success) {
if (isLoadingMore) {
// Append new comments
setComments((prev) => [...prev, ...(result.comments || [])]);
} else {
// Replace all comments
setComments(result.comments || []);
}
setTotalCount(result.totalCount || 0);
setHasNextPage(result.hasNextPage || false);
setEndCursor(result.endCursor);
} else {
setError(result.error || 'Failed to fetch comments');
}
}
}
} catch (err) {
if (isMountedRef.current) {
console.error('[useIssueComments] Error fetching comments:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch comments');
}
} finally {
if (isMountedRef.current) {
setLoading(false);
setLoadingMore(false);
}
}
},
[currentProject?.path, issueNumber]
);
// Reset and fetch when issue changes
useEffect(() => {
isMountedRef.current = true;
if (issueNumber) {
// Reset state when issue changes
setComments([]);
setTotalCount(0);
setHasNextPage(false);
setEndCursor(undefined);
setError(null);
fetchComments();
} else {
// Clear comments when no issue is selected
setComments([]);
setTotalCount(0);
setHasNextPage(false);
setEndCursor(undefined);
setLoading(false);
setError(null);
}
return () => {
isMountedRef.current = false;
};
}, [issueNumber, fetchComments]);
const loadMore = useCallback(() => {
if (hasNextPage && endCursor && !loadingMore) {
fetchComments(endCursor);
}
}, [hasNextPage, endCursor, loadingMore, fetchComments]);
const refresh = useCallback(() => {
setComments([]);
setEndCursor(undefined);
fetchComments();
}, [fetchComments]);
return {
comments,
totalCount,
loading,
loadingMore,
hasNextPage,
error,
loadMore,
refresh,
};
}

View File

@@ -2,10 +2,12 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import {
getElectronAPI,
GitHubIssue,
GitHubComment,
IssueValidationResult,
IssueValidationEvent,
StoredValidation,
} from '@/lib/electron';
import type { LinkedPRInfo } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { toast } from 'sonner';
import { isValidationStale } from '../utils';
@@ -205,8 +207,15 @@ export function useIssueValidation({
}, []);
const handleValidateIssue = useCallback(
async (issue: GitHubIssue, options: { forceRevalidate?: boolean } = {}) => {
const { forceRevalidate = false } = options;
async (
issue: GitHubIssue,
options: {
forceRevalidate?: boolean;
comments?: GitHubComment[];
linkedPRs?: LinkedPRInfo[];
} = {}
) => {
const { forceRevalidate = false, comments, linkedPRs } = options;
if (!currentProject?.path) {
toast.error('No project selected');
@@ -236,14 +245,17 @@ export function useIssueValidation({
try {
const api = getElectronAPI();
if (api.github?.validateIssue) {
const validationInput = {
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
comments, // Include comments if provided
linkedPRs, // Include linked PRs if provided
};
const result = await api.github.validateIssue(
currentProject.path,
{
issueNumber: issue.number,
issueTitle: issue.title,
issueBody: issue.body || '',
issueLabels: issue.labels.map((l) => l.name),
},
validationInput,
validationModel
);

View File

@@ -1,4 +1,5 @@
import type { GitHubIssue, StoredValidation } from '@/lib/electron';
import type { GitHubIssue, StoredValidation, GitHubComment } from '@/lib/electron';
import type { LinkedPRInfo } from '@automaker/types';
export interface IssueRowProps {
issue: GitHubIssue;
@@ -12,17 +13,25 @@ export interface IssueRowProps {
isValidating?: boolean;
}
/** Options for issue validation */
export interface ValidateIssueOptions {
showDialog?: boolean;
forceRevalidate?: boolean;
/** Include comments in AI analysis */
comments?: GitHubComment[];
/** Linked pull requests */
linkedPRs?: LinkedPRInfo[];
}
export interface IssueDetailPanelProps {
issue: GitHubIssue;
validatingIssues: Set<number>;
cachedValidations: Map<number, StoredValidation>;
onValidateIssue: (
issue: GitHubIssue,
options?: { showDialog?: boolean; forceRevalidate?: boolean }
) => Promise<void>;
onValidateIssue: (issue: GitHubIssue, options?: ValidateIssueOptions) => Promise<void>;
onViewCachedValidation: (issue: GitHubIssue) => Promise<void>;
onOpenInGitHub: (url: string) => void;
onClose: () => void;
onShowRevalidateConfirm: () => void;
/** Called when user wants to revalidate - receives the validation options including comments/linkedPRs */
onShowRevalidateConfirm: (options: ValidateIssueOptions) => void;
formatDate: (date: string) => string;
}

View File

@@ -11,6 +11,8 @@ import type {
IssueValidationEvent,
StoredValidation,
AgentModel,
GitHubComment,
IssueCommentsResult,
} from '@automaker/types';
import { getJSON, setJSON, removeItem } from './storage';
@@ -24,6 +26,8 @@ export type {
IssueValidationResponse,
IssueValidationEvent,
StoredValidation,
GitHubComment,
IssueCommentsResult,
};
export interface FileEntry {
@@ -234,6 +238,19 @@ export interface GitHubAPI {
) => Promise<{ success: boolean; error?: string }>;
/** Subscribe to validation events */
onValidationEvent: (callback: (event: IssueValidationEvent) => void) => () => void;
/** Fetch comments for a specific issue */
getIssueComments: (
projectPath: string,
issueNumber: number,
cursor?: string
) => Promise<{
success: boolean;
comments?: GitHubComment[];
totalCount?: number;
hasNextPage?: boolean;
endCursor?: string;
error?: string;
}>;
}
// Feature Suggestions types
@@ -2786,6 +2803,15 @@ function createMockGitHubAPI(): GitHubAPI {
mockValidationCallbacks = mockValidationCallbacks.filter((cb) => cb !== callback);
};
},
getIssueComments: async (projectPath: string, issueNumber: number, cursor?: string) => {
console.log('[Mock] Getting issue comments:', { projectPath, issueNumber, cursor });
return {
success: true,
comments: [],
totalCount: 0,
hasNextPage: false,
};
},
};
}

View File

@@ -766,6 +766,8 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/github/validation-mark-viewed', { projectPath, issueNumber }),
onValidationEvent: (callback: (event: IssueValidationEvent) => void) =>
this.subscribeToEvent('issue-validation:event', callback as EventCallback),
getIssueComments: (projectPath: string, issueNumber: number, cursor?: string) =>
this.post('/api/github/issue-comments', { projectPath, issueNumber, cursor }),
};
// Workspace API