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

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