diff --git a/apps/server/src/routes/github/index.ts b/apps/server/src/routes/github/index.ts index 1a2f12ae..dddae96e 100644 --- a/apps/server/src/routes/github/index.ts +++ b/apps/server/src/routes/github/index.ts @@ -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'), diff --git a/apps/server/src/routes/github/routes/list-comments.ts b/apps/server/src/routes/github/routes/list-comments.ts new file mode 100644 index 00000000..1813923e --- /dev/null +++ b/apps/server/src/routes/github/routes/list-comments.ts @@ -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 { + 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((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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index c987453a..d7c9ffc0 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -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 { // 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) diff --git a/apps/server/src/routes/github/routes/validation-schema.ts b/apps/server/src/routes/github/routes/validation-schema.ts index 50812082..6716905b 100644 --- a/apps/server/src/routes/github/routes/validation-schema.ts +++ b/apps/server/src/routes/github/routes/validation-schema.ts @@ -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.' : ''}`; } diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 10876e38..fa41972e 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -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(null); const [validationResult, setValidationResult] = useState(null); const [showValidationDialog, setShowValidationDialog] = useState(false); const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false); + const [pendingRevalidateOptions, setPendingRevalidateOptions] = + useState(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 */} { + 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); } }} /> diff --git a/apps/ui/src/components/views/github-issues-view/components/comment-item.tsx b/apps/ui/src/components/views/github-issues-view/components/comment-item.tsx new file mode 100644 index 00000000..c4a60cda --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/comment-item.tsx @@ -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 ( +
+ {/* Comment Header */} +
+ {comment.author.avatarUrl ? ( + {comment.author.login} + ) : ( +
+ +
+ )} + {comment.author.login} + + commented {formatDate(comment.createdAt)} + +
+ + {/* Comment Body */} + {comment.body ? ( + {comment.body} + ) : ( +

No content

+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/components/index.ts b/apps/ui/src/components/views/github-issues-view/components/index.ts index b3af9f84..6ed7e4d0 100644 --- a/apps/ui/src/components/views/github-issues-view/components/index.ts +++ b/apps/ui/src/components/views/github-issues-view/components/index.ts @@ -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'; diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx index 7969da38..831a6ce2 100644 --- a/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx +++ b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx @@ -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 (
{/* Detail Header */} @@ -67,7 +107,7 @@ export function IssueDetailPanel({ @@ -226,6 +270,76 @@ export function IssueDetailPanel({

No description provided.

)} + {/* Comments Section */} +
+
+ + {comments.length > 0 && ( + + )} +
+ + {commentsExpanded && ( +
+ {commentsError ? ( +

{commentsError}

+ ) : comments.length === 0 && !commentsLoading ? ( +

No comments yet.

+ ) : ( +
+ {comments.map((comment) => ( + + ))} + + {/* Load More Button */} + {hasNextPage && ( + + )} +
+ )} +
+ )} +
+ {/* Open in GitHub CTA */}

diff --git a/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx b/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx index fba1a9ea..7ac04ea3 100644 --- a/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx +++ b/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx @@ -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({

)} + {/* Work in Progress Badge - Show when there's an open PR linked */} + {issue.linkedPRs?.some((pr) => pr.state === 'open' || pr.state === 'OPEN') && ( +
+ +
+ Work in Progress +

+ {issue.linkedPRs + .filter((pr) => pr.state === 'open' || pr.state === 'OPEN') + .map((pr) => `PR #${pr.number}`) + .join(', ')}{' '} + is open for this issue +

+
+
+ )} + {/* Reasoning */}

diff --git a/apps/ui/src/components/views/github-issues-view/hooks/index.ts b/apps/ui/src/components/views/github-issues-view/hooks/index.ts index c3417416..57b78868 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/index.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/index.ts @@ -1,2 +1,3 @@ export { useGithubIssues } from './use-github-issues'; export { useIssueValidation } from './use-issue-validation'; +export { useIssueComments } from './use-issue-comments'; diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts new file mode 100644 index 00000000..e0505b36 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-comments.ts @@ -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([]); + const [totalCount, setTotalCount] = useState(0); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasNextPage, setHasNextPage] = useState(false); + const [endCursor, setEndCursor] = useState(undefined); + const [error, setError] = useState(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, + }; +} diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts index 136185c5..198beef9 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts @@ -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 ); diff --git a/apps/ui/src/components/views/github-issues-view/types.ts b/apps/ui/src/components/views/github-issues-view/types.ts index 9fce6d53..5bf1fd0c 100644 --- a/apps/ui/src/components/views/github-issues-view/types.ts +++ b/apps/ui/src/components/views/github-issues-view/types.ts @@ -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; cachedValidations: Map; - onValidateIssue: ( - issue: GitHubIssue, - options?: { showDialog?: boolean; forceRevalidate?: boolean } - ) => Promise; + onValidateIssue: (issue: GitHubIssue, options?: ValidateIssueOptions) => Promise; onViewCachedValidation: (issue: GitHubIssue) => Promise; 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; } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 698f915e..bafa5cf3 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -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, + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index ddbfd51a..1efcbcd1 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -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 diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index ad45206a..11d3c39d 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -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 diff --git a/libs/types/src/issue-validation.ts b/libs/types/src/issue-validation.ts index 2c0d2f64..89131b6c 100644 --- a/libs/types/src/issue-validation.ts +++ b/libs/types/src/issue-validation.ts @@ -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; +}