feat: add GitHub issue comments display and AI validation integration

- Add comments section to issue detail panel with lazy loading
- Fetch comments via GraphQL API with pagination (50 at a time)
- Include comments in AI validation analysis when checkbox enabled
- Pass linked PRs info to AI validation for context
- Add "Work in Progress" badge in validation dialog for open PRs
- Add debug logging for validation requests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-28 22:11:02 +01:00
parent 61881d99e2
commit 96196f906f
17 changed files with 777 additions and 28 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,183 @@
/**
* 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 }>;
}
/**
* 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> {
const cursorParam = cursor ? `, after: "${cursor}"` : '';
const query = `{
repository(owner: "${owner}", name: "${repo}") {
issue(number: ${issueNumber}) {
comments(first: 50${cursorParam}) {
totalCount
pageInfo {
hasNextPage
endCursor
}
nodes {
id
author {
login
avatarUrl
}
body
createdAt
updatedAt
}
}
}
}
}`;
const requestBody = JSON.stringify({ query });
const response = await new Promise<GraphQLResponse>((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();
});
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,7 +8,13 @@
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 {
@@ -29,6 +35,24 @@ import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
/** Valid model values for validation */
const VALID_MODELS: readonly AgentModel[] = ['opus', 'sonnet', 'haiku'] as const;
/**
* Comment structure for validation prompt
*/
interface ValidationComment {
author: string;
createdAt: string;
body: string;
}
/**
* Linked PR structure for validation prompt
*/
interface ValidationLinkedPR {
number: number;
title: string;
state: string;
}
/**
* Request body for issue validation
*/
@@ -40,6 +64,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 +85,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 +106,28 @@ 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)
logger.info(
`Building validation prompt for issue #${issueNumber}` +
(comments?.length ? ` with ${comments.length} comments` : ' without comments') +
(linkedPRs?.length ? ` and ${linkedPRs.length} linked PRs` : '')
);
if (comments?.length) {
logger.debug(`Comments included: ${comments.map((c) => c.author).join(', ')}`);
}
if (linkedPRs?.length) {
logger.debug(
`Linked PRs: ${linkedPRs.map((pr) => `#${pr.number} (${pr.state})`).join(', ')}`
);
}
const prompt = buildValidationPrompt(
issueNumber,
issueTitle,
issueBody,
issueLabels,
comments,
linkedPRs
);
// Load autoLoadClaudeMd setting
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
@@ -214,8 +264,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,7 +343,9 @@ export function createValidateIssueHandler(
model,
events,
abortController,
settingsService
settingsService,
validationComments,
validationLinkedPRs
)
.catch((error) => {
// Error is already handled inside runValidation (event emitted)

View File

@@ -103,6 +103,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
*/
interface ValidationComment {
author: string;
createdAt: string;
body: string;
}
/**
* Linked PR data structure for validation prompt
*/
interface ValidationLinkedPR {
number: number;
title: string;
state: string;
}
/**
* Build the user prompt for issue validation.
*
@@ -113,26 +131,58 @@ 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).toLocaleDateString()}):\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,18 @@ import {
GitPullRequest,
User,
RefreshCw,
MessageSquare,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
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 +38,40 @@ 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) => {
const options = {
forceRevalidate,
comments: includeCommentsInAnalysis && comments.length > 0 ? comments : undefined,
linkedPRs: issue.linkedPRs?.map((pr) => ({
number: pr.number,
title: pr.title,
state: pr.state,
})),
};
console.log('[IssueDetailPanel] getValidationOptions:', {
includeCommentsInAnalysis,
commentsCount: comments.length,
linkedPRsCount: issue.linkedPRs?.length ?? 0,
willIncludeComments: !!options.comments,
willIncludeLinkedPRs: !!options.linkedPRs,
});
return options;
};
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Detail Header */}
@@ -67,7 +107,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 +126,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 +136,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 +270,76 @@ 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-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={includeCommentsInAnalysis}
onChange={(e) => setIncludeCommentsInAnalysis(e.target.checked)}
className="h-3 w-3 rounded border-border"
/>
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,7 @@ import {
Lightbulb,
AlertTriangle,
Plus,
GitPullRequest,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type {
@@ -149,6 +150,23 @@ export function ValidationDialog({
</div>
)}
{/* Work in Progress Badge - Show when there's an open PR linked */}
{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">

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,133 @@
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);
}
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,23 @@ 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;
console.log('[useIssueValidation] handleValidateIssue called with:', {
issueNumber: issue.number,
forceRevalidate,
commentsProvided: !!comments,
commentsCount: comments?.length ?? 0,
linkedPRsProvided: !!linkedPRs,
linkedPRsCount: linkedPRs?.length ?? 0,
});
if (!currentProject?.path) {
toast.error('No project selected');
@@ -236,14 +253,23 @@ 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
};
console.log('[useIssueValidation] Sending validation request:', {
hasComments: !!validationInput.comments,
commentsCount: validationInput.comments?.length ?? 0,
hasLinkedPRs: !!validationInput.linkedPRs,
linkedPRsCount: validationInput.linkedPRs?.length ?? 0,
});
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

View File

@@ -87,6 +87,7 @@ export type {
IssueValidationVerdict,
IssueValidationConfidence,
IssueComplexity,
LinkedPRInfo,
IssueValidationInput,
IssueValidationRequest,
IssueValidationResult,
@@ -94,6 +95,9 @@ export type {
IssueValidationErrorResponse,
IssueValidationEvent,
StoredValidation,
GitHubCommentAuthor,
GitHubComment,
IssueCommentsResult,
} from './issue-validation.js';
// Backlog plan types

View File

@@ -21,6 +21,15 @@ export type IssueValidationConfidence = 'high' | 'medium' | 'low';
*/
export type IssueComplexity = 'trivial' | 'simple' | 'moderate' | 'complex' | 'very_complex';
/**
* Linked PR info for validation
*/
export interface LinkedPRInfo {
number: number;
title: string;
state: string;
}
/**
* Issue data for validation (without projectPath)
* Used by UI when calling the validation API
@@ -30,6 +39,10 @@ export interface IssueValidationInput {
issueTitle: string;
issueBody: string;
issueLabels?: string[];
/** Comments to include in validation analysis */
comments?: GitHubComment[];
/** Linked pull requests for this issue */
linkedPRs?: LinkedPRInfo[];
}
/**
@@ -133,3 +146,41 @@ export interface StoredValidation {
/** ISO timestamp when user viewed this validation (undefined = not yet viewed) */
viewedAt?: string;
}
/**
* Author of a GitHub comment
*/
export interface GitHubCommentAuthor {
login: string;
avatarUrl?: string;
}
/**
* A comment on a GitHub issue
*/
export interface GitHubComment {
/** Unique comment ID */
id: string;
/** Author of the comment */
author: GitHubCommentAuthor;
/** Comment body (markdown) */
body: string;
/** ISO timestamp when comment was created */
createdAt: string;
/** ISO timestamp when comment was last updated */
updatedAt?: string;
}
/**
* Result from fetching issue comments
*/
export interface IssueCommentsResult {
/** List of comments */
comments: GitHubComment[];
/** Total number of comments on the issue */
totalCount: number;
/** Whether there are more comments to fetch */
hasNextPage: boolean;
/** Cursor for pagination (pass to next request) */
endCursor?: string;
}