From a881d175bc97763bd40fa1bfaadfa87c9f1d8739 Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 23 Dec 2025 15:50:10 +0100 Subject: [PATCH 01/18] feat: Implement GitHub issue validation endpoint and UI integration - Added a new endpoint for validating GitHub issues using the Claude SDK. - Introduced validation schema and logic to handle issue validation requests. - Updated GitHub routes to include the new validation route. - Enhanced the UI with a validation dialog and button to trigger issue validation. - Mapped issue complexity to feature priority for better task management. - Integrated validation results display in the UI, allowing users to convert validated issues into tasks. --- apps/server/src/routes/github/index.ts | 9 +- .../routes/github/routes/validate-issue.ts | 164 ++++++++++++ .../routes/github/routes/validation-schema.ts | 138 ++++++++++ .../components/views/github-issues-view.tsx | 160 +++++++++++- .../github-issues-view/validation-dialog.tsx | 238 ++++++++++++++++++ apps/ui/src/lib/electron.ts | 38 +++ apps/ui/src/lib/http-api-client.ts | 3 + libs/types/src/index.ts | 12 + libs/types/src/issue-validation.ts | 78 ++++++ 9 files changed, 835 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/routes/github/routes/validate-issue.ts create mode 100644 apps/server/src/routes/github/routes/validation-schema.ts create mode 100644 apps/ui/src/components/views/github-issues-view/validation-dialog.tsx create mode 100644 libs/types/src/issue-validation.ts diff --git a/apps/server/src/routes/github/index.ts b/apps/server/src/routes/github/index.ts index bda4d217..57b7cad2 100644 --- a/apps/server/src/routes/github/index.ts +++ b/apps/server/src/routes/github/index.ts @@ -3,16 +3,19 @@ */ import { Router } from 'express'; +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 { createValidateIssueHandler } from './routes/validate-issue.js'; export function createGitHubRoutes(): Router { const router = Router(); - router.post('/check-remote', createCheckGitHubRemoteHandler()); - router.post('/issues', createListIssuesHandler()); - router.post('/prs', createListPRsHandler()); + router.post('/check-remote', validatePathParams('projectPath'), createCheckGitHubRemoteHandler()); + router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler()); + router.post('/prs', validatePathParams('projectPath'), createListPRsHandler()); + router.post('/validate-issue', validatePathParams('projectPath'), createValidateIssueHandler()); return router; } diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts new file mode 100644 index 00000000..3bb7a66e --- /dev/null +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -0,0 +1,164 @@ +/** + * POST /validate-issue endpoint - Validate a GitHub issue using Claude SDK + * + * Scans the codebase to determine if an issue is valid, invalid, or needs clarification. + */ + +import type { Request, Response } from 'express'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import type { IssueValidationResult } from '@automaker/types'; +import { createSuggestionsOptions } from '../../../lib/sdk-options.js'; +import { + issueValidationSchema, + ISSUE_VALIDATION_SYSTEM_PROMPT, + buildValidationPrompt, +} from './validation-schema.js'; +import { getErrorMessage, logError } from './common.js'; + +const logger = createLogger('IssueValidation'); + +/** + * Request body for issue validation + */ +interface ValidateIssueRequestBody { + projectPath: string; + issueNumber: number; + issueTitle: string; + issueBody: string; + issueLabels?: string[]; +} + +/** + * Creates the handler for validating GitHub issues against the codebase. + * + * Uses Claude SDK with: + * - Read-only tools (Read, Glob, Grep) for codebase analysis + * - JSON schema structured output for reliable parsing + * - System prompt guiding the validation process + */ +export function createValidateIssueHandler() { + return async (req: Request, res: Response): Promise => { + // Declare timeoutId outside try block for proper cleanup + let timeoutId: ReturnType | undefined; + + try { + const { projectPath, issueNumber, issueTitle, issueBody, issueLabels } = + req.body as ValidateIssueRequestBody; + + // Validate required fields + 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; + } + + if (!issueTitle || typeof issueTitle !== 'string') { + res.status(400).json({ success: false, error: 'issueTitle is required' }); + return; + } + + if (typeof issueBody !== 'string') { + res.status(400).json({ success: false, error: 'issueBody must be a string' }); + return; + } + + logger.info(`Validating issue #${issueNumber}: ${issueTitle}`); + + // Build the prompt + const prompt = buildValidationPrompt(issueNumber, issueTitle, issueBody, issueLabels); + + // Create abort controller with 2 minute timeout for validation + const abortController = new AbortController(); + const VALIDATION_TIMEOUT_MS = 120000; // 2 minutes + timeoutId = setTimeout(() => { + logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`); + abortController.abort(); + }, VALIDATION_TIMEOUT_MS); + + // Create SDK options with structured output and abort controller + const options = createSuggestionsOptions({ + cwd: projectPath, + systemPrompt: ISSUE_VALIDATION_SYSTEM_PROMPT, + abortController, + outputFormat: { + type: 'json_schema', + schema: issueValidationSchema as Record, + }, + }); + + // 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/fallback + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text') { + responseText += block.text; + } + } + } + + // Extract structured output on success + if (msg.type === 'result' && msg.subtype === 'success') { + const resultMsg = msg as { structured_output?: IssueValidationResult }; + if (resultMsg.structured_output) { + validationResult = resultMsg.structured_output; + logger.debug('Received structured output:', validationResult); + } + } + + // Handle errors + if (msg.type === 'result') { + const resultMsg = msg as { subtype?: string }; + if (resultMsg.subtype === 'error_max_structured_output_retries') { + logger.error('Failed to produce valid structured output after retries'); + throw new Error('Could not produce valid validation output'); + } + } + } + + // Require structured output - no fragile fallback parsing + 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'); + } + + // Clear the timeout since we completed successfully + clearTimeout(timeoutId); + + logger.info(`Issue #${issueNumber} validation complete: ${validationResult.verdict}`); + res.json({ + success: true, + issueNumber, + validation: validationResult, + }); + } catch (error) { + // Clear timeout on error as well (if it was set) + if (timeoutId) { + clearTimeout(timeoutId); + } + + logError(error, `Issue validation failed`); + logger.error('Issue validation error:', error); + + // Check if response already sent + if (!res.headersSent) { + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + } + }; +} diff --git a/apps/server/src/routes/github/routes/validation-schema.ts b/apps/server/src/routes/github/routes/validation-schema.ts new file mode 100644 index 00000000..50812082 --- /dev/null +++ b/apps/server/src/routes/github/routes/validation-schema.ts @@ -0,0 +1,138 @@ +/** + * Issue Validation Schema and System Prompt + * + * Defines the JSON schema for Claude's structured output and + * the system prompt that guides the validation process. + */ + +/** + * JSON Schema for issue validation structured output. + * Used with Claude SDK's outputFormat option to ensure reliable parsing. + */ +export const issueValidationSchema = { + type: 'object', + properties: { + verdict: { + type: 'string', + enum: ['valid', 'invalid', 'needs_clarification'], + description: 'The validation verdict for the issue', + }, + confidence: { + type: 'string', + enum: ['high', 'medium', 'low'], + description: 'How confident the AI is in its assessment', + }, + reasoning: { + type: 'string', + description: 'Detailed explanation of the verdict', + }, + bugConfirmed: { + type: 'boolean', + description: 'For bug reports: whether the bug was confirmed in the codebase', + }, + relatedFiles: { + type: 'array', + items: { type: 'string' }, + description: 'Files related to the issue found during analysis', + }, + suggestedFix: { + type: 'string', + description: 'Suggested approach to fix or implement the issue', + }, + missingInfo: { + type: 'array', + items: { type: 'string' }, + description: 'Information needed when verdict is needs_clarification', + }, + estimatedComplexity: { + type: 'string', + enum: ['trivial', 'simple', 'moderate', 'complex', 'very_complex'], + description: 'Estimated effort to address the issue', + }, + }, + required: ['verdict', 'confidence', 'reasoning'], + additionalProperties: false, +} as const; + +/** + * System prompt that guides Claude in validating GitHub issues. + * Instructs the model to use read-only tools to analyze the codebase. + */ +export const ISSUE_VALIDATION_SYSTEM_PROMPT = `You are an expert code analyst validating GitHub issues against a codebase. + +Your task is to analyze a GitHub issue and determine if it's valid by scanning the codebase. + +## Validation Process + +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 + +## Verdicts + +- **valid**: The issue describes a real problem that exists in the codebase, or a clear feature request that can be implemented. The referenced files/components exist and the issue is actionable. + +- **invalid**: The issue describes behavior that doesn't exist, references non-existent files or components, is based on a misunderstanding of the code, or the described "bug" is actually expected behavior. + +- **needs_clarification**: The issue lacks sufficient detail to verify. Specify what additional information is needed in the missingInfo field. + +## For Bug Reports, Check: +- Do the referenced files/components exist? +- Does the code match what the issue describes? +- Is the described behavior actually a bug or expected? +- Can you locate the code that would cause the reported issue? + +## For Feature Requests, Check: +- Does the feature already exist? +- Is the implementation location clear? +- Is the request technically feasible given the codebase structure? + +## 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 +- **Set estimatedComplexity** to help prioritize: + - trivial: Simple text changes, one-line fixes + - simple: Small changes to one file + - moderate: Changes to multiple files or moderate logic changes + - complex: Significant refactoring or new feature implementation + - very_complex: Major architectural changes or cross-cutting concerns + +Be thorough in your analysis but focus on files that are directly relevant to the issue.`; + +/** + * Build the user prompt for issue validation. + * + * Creates a structured prompt that includes the issue details for Claude + * to analyze against the codebase. + * + * @param issueNumber - The GitHub issue number + * @param issueTitle - The issue title + * @param issueBody - The issue body/description + * @param issueLabels - Optional array of label names + * @returns Formatted prompt string for the validation request + */ +export function buildValidationPrompt( + issueNumber: number, + issueTitle: string, + issueBody: string, + issueLabels?: string[] +): string { + const labelsSection = issueLabels?.length ? `\n\n**Labels:** ${issueLabels.join(', ')}` : ''; + + return `Please validate the following GitHub issue by analyzing the codebase: + +## Issue #${issueNumber}: ${issueTitle} +${labelsSection} + +### Description + +${issueBody || '(No description provided)'} + +--- + +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.`; +} diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 941b5db8..ef760833 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -1,10 +1,43 @@ import { useState, useEffect, useCallback } from 'react'; -import { CircleDot, Loader2, RefreshCw, ExternalLink, CheckCircle2, Circle, X } from 'lucide-react'; -import { getElectronAPI, GitHubIssue } from '@/lib/electron'; +import { + CircleDot, + Loader2, + RefreshCw, + ExternalLink, + CheckCircle2, + Circle, + X, + Wand2, +} from 'lucide-react'; +import { + getElectronAPI, + GitHubIssue, + IssueValidationResult, + IssueComplexity, +} from '@/lib/electron'; + +/** + * Map issue complexity to feature priority. + * Lower complexity issues get higher priority (1 = high, 2 = medium). + */ +function getFeaturePriority(complexity: IssueComplexity | undefined): number { + switch (complexity) { + case 'trivial': + case 'simple': + return 1; // High priority for easy wins + case 'moderate': + case 'complex': + case 'very_complex': + default: + return 2; // Medium priority for larger efforts + } +} import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { Markdown } from '@/components/ui/markdown'; import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { ValidationDialog } from './github-issues-view/validation-dialog'; export function GitHubIssuesView() { const [openIssues, setOpenIssues] = useState([]); @@ -13,6 +46,9 @@ export function GitHubIssuesView() { const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [selectedIssue, setSelectedIssue] = useState(null); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [showValidationDialog, setShowValidationDialog] = useState(false); const { currentProject } = useAppStore(); const fetchIssues = useCallback(async () => { @@ -57,6 +93,103 @@ export function GitHubIssuesView() { api.openExternalLink(url); }, []); + const handleValidateIssue = useCallback( + async (issue: GitHubIssue) => { + if (!currentProject?.path) { + toast.error('No project selected'); + return; + } + + setValidating(true); + setValidationResult(null); + setShowValidationDialog(true); + + try { + const api = getElectronAPI(); + if (api.github?.validateIssue) { + const result = await api.github.validateIssue(currentProject.path, { + issueNumber: issue.number, + issueTitle: issue.title, + issueBody: issue.body || '', + issueLabels: issue.labels.map((l) => l.name), + }); + + if (result.success) { + setValidationResult(result.validation); + } else { + toast.error(result.error || 'Failed to validate issue'); + setShowValidationDialog(false); + } + } + } catch (err) { + console.error('[GitHubIssuesView] Validation error:', err); + toast.error(err instanceof Error ? err.message : 'Failed to validate issue'); + setShowValidationDialog(false); + } finally { + setValidating(false); + } + }, + [currentProject?.path] + ); + + const handleConvertToTask = useCallback( + async (issue: GitHubIssue, validation: IssueValidationResult) => { + if (!currentProject?.path) { + toast.error('No project selected'); + return; + } + + try { + const api = getElectronAPI(); + if (api.features?.create) { + // Build description from issue body + validation info + const description = [ + `**From GitHub Issue #${issue.number}**`, + '', + issue.body || 'No description provided.', + '', + '---', + '', + '**AI Validation Analysis:**', + validation.reasoning, + validation.suggestedFix ? `\n**Suggested Approach:**\n${validation.suggestedFix}` : '', + validation.relatedFiles?.length + ? `\n**Related Files:**\n${validation.relatedFiles.map((f) => `- \`${f}\``).join('\n')}` + : '', + ] + .filter(Boolean) + .join('\n'); + + const feature = { + id: `issue-${issue.number}-${crypto.randomUUID()}`, + title: issue.title, + description, + category: 'From GitHub', + status: 'backlog' as const, + passes: false, + priority: getFeaturePriority(validation.estimatedComplexity), + model: 'opus' as const, + thinkingLevel: 'none' as const, + branchName: '', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + const result = await api.features.create(currentProject.path, feature); + if (result.success) { + toast.success(`Created task: ${issue.title}`); + } else { + toast.error(result.error || 'Failed to create task'); + } + } + } catch (err) { + console.error('[GitHubIssuesView] Convert to task error:', err); + toast.error(err instanceof Error ? err.message : 'Failed to create task'); + } + }, + [currentProject?.path] + ); + const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { @@ -184,6 +317,19 @@ export function GitHubIssuesView() {
+
)} + + {/* Validation Dialog */} + ); } diff --git a/apps/ui/src/components/views/github-issues-view/validation-dialog.tsx b/apps/ui/src/components/views/github-issues-view/validation-dialog.tsx new file mode 100644 index 00000000..5002d27a --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/validation-dialog.tsx @@ -0,0 +1,238 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + CheckCircle2, + XCircle, + AlertCircle, + FileCode, + Lightbulb, + AlertTriangle, + Loader2, + Plus, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { + IssueValidationResult, + IssueValidationVerdict, + IssueValidationConfidence, + IssueComplexity, + GitHubIssue, +} from '@/lib/electron'; + +interface ValidationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + issue: GitHubIssue | null; + validationResult: IssueValidationResult | null; + isValidating: boolean; + onConvertToTask?: (issue: GitHubIssue, validation: IssueValidationResult) => void; +} + +const verdictConfig: Record< + IssueValidationVerdict, + { label: string; color: string; bgColor: string; icon: typeof CheckCircle2 } +> = { + valid: { + label: 'Valid', + color: 'text-green-500', + bgColor: 'bg-green-500/10', + icon: CheckCircle2, + }, + invalid: { + label: 'Invalid', + color: 'text-red-500', + bgColor: 'bg-red-500/10', + icon: XCircle, + }, + needs_clarification: { + label: 'Needs Clarification', + color: 'text-yellow-500', + bgColor: 'bg-yellow-500/10', + icon: AlertCircle, + }, +}; + +const confidenceConfig: Record = { + high: { label: 'High Confidence', color: 'text-green-500' }, + medium: { label: 'Medium Confidence', color: 'text-yellow-500' }, + low: { label: 'Low Confidence', color: 'text-orange-500' }, +}; + +const complexityConfig: Record = { + trivial: { label: 'Trivial', color: 'text-green-500' }, + simple: { label: 'Simple', color: 'text-blue-500' }, + moderate: { label: 'Moderate', color: 'text-yellow-500' }, + complex: { label: 'Complex', color: 'text-orange-500' }, + very_complex: { label: 'Very Complex', color: 'text-red-500' }, +}; + +export function ValidationDialog({ + open, + onOpenChange, + issue, + validationResult, + isValidating, + onConvertToTask, +}: ValidationDialogProps) { + if (!issue) return null; + + const handleConvertToTask = () => { + if (validationResult && onConvertToTask) { + onConvertToTask(issue, validationResult); + onOpenChange(false); + } + }; + + return ( + + + + Issue Validation Result + + #{issue.number}: {issue.title} + + + + {isValidating ? ( +
+ +

Analyzing codebase to validate issue...

+
+ ) : validationResult ? ( +
+ {/* Verdict Badge */} +
+
+ {(() => { + const config = verdictConfig[validationResult.verdict]; + const Icon = config.icon; + return ( + <> +
+ +
+
+

{config.label}

+

+ {confidenceConfig[validationResult.confidence].label} +

+
+ + ); + })()} +
+ {validationResult.estimatedComplexity && ( +
+

Estimated Complexity

+

+ {complexityConfig[validationResult.estimatedComplexity].label} +

+
+ )} +
+ + {/* Bug Confirmed Badge */} + {validationResult.bugConfirmed && ( +
+ + Bug Confirmed in Codebase +
+ )} + + {/* Reasoning */} +
+

+ + Analysis +

+

+ {validationResult.reasoning} +

+
+ + {/* Related Files */} + {validationResult.relatedFiles && validationResult.relatedFiles.length > 0 && ( +
+

+ + Related Files +

+
+ {validationResult.relatedFiles.map((file, index) => ( +
+ {file} +
+ ))} +
+
+ )} + + {/* Suggested Fix */} + {validationResult.suggestedFix && ( +
+

Suggested Approach

+

+ {validationResult.suggestedFix} +

+
+ )} + + {/* Missing Info (for needs_clarification) */} + {validationResult.missingInfo && validationResult.missingInfo.length > 0 && ( +
+

+ + Missing Information +

+
    + {validationResult.missingInfo.map((info, index) => ( +
  • + {info} +
  • + ))} +
+
+ )} +
+ ) : ( +
+ +

No validation result available.

+
+ )} + + + + {validationResult?.verdict === 'valid' && onConvertToTask && ( + + )} + +
+
+ ); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index f5b3e922..66631a9b 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,8 +1,26 @@ // Type definitions for Electron IPC API import type { SessionListItem, Message } from '@/types/electron'; import type { ClaudeUsageResponse } from '@/store/app-store'; +import type { + IssueValidationVerdict, + IssueValidationConfidence, + IssueComplexity, + IssueValidationInput, + IssueValidationResult, + IssueValidationResponse, +} from '@automaker/types'; import { getJSON, setJSON, removeItem } from './storage'; +// Re-export issue validation types for use in components +export type { + IssueValidationVerdict, + IssueValidationConfidence, + IssueComplexity, + IssueValidationInput, + IssueValidationResult, + IssueValidationResponse, +}; + export interface FileEntry { name: string; isDirectory: boolean; @@ -156,6 +174,10 @@ export interface GitHubAPI { mergedPRs?: GitHubPR[]; error?: string; }>; + validateIssue: ( + projectPath: string, + issue: IssueValidationInput + ) => Promise; } // Feature Suggestions types @@ -2631,6 +2653,22 @@ function createMockGitHubAPI(): GitHubAPI { mergedPRs: [], }; }, + validateIssue: async (projectPath: string, issue: IssueValidationInput) => { + console.log('[Mock] Validating GitHub issue:', { projectPath, issue }); + // Return a mock validation result + return { + success: true as const, + issueNumber: issue.issueNumber, + validation: { + verdict: 'valid' as const, + confidence: 'medium' as const, + reasoning: + 'This is a mock validation. In production, Claude SDK would analyze the codebase to validate this issue.', + relatedFiles: ['src/components/example.tsx'], + estimatedComplexity: 'moderate' as const, + }, + }; + }, }; } diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 00b96d6b..2b35c48b 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -24,6 +24,7 @@ import type { GitHubAPI, GitHubIssue, GitHubPR, + IssueValidationInput, } from './electron'; import type { Message, SessionListItem } from '@/types/electron'; import type { Feature, ClaudeUsageResponse } from '@/store/app-store'; @@ -751,6 +752,8 @@ export class HttpApiClient implements ElectronAPI { checkRemote: (projectPath: string) => this.post('/api/github/check-remote', { projectPath }), listIssues: (projectPath: string) => this.post('/api/github/issues', { projectPath }), listPRs: (projectPath: string) => this.post('/api/github/prs', { projectPath }), + validateIssue: (projectPath: string, issue: IssueValidationInput) => + this.post('/api/github/validate-issue', { projectPath, ...issue }), }; // Workspace API diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 6e173075..1c0d717c 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -81,3 +81,15 @@ export { THINKING_LEVEL_LABELS, getModelDisplayName, } from './model-display.js'; + +// Issue validation types +export type { + IssueValidationVerdict, + IssueValidationConfidence, + IssueComplexity, + IssueValidationInput, + IssueValidationRequest, + IssueValidationResult, + IssueValidationResponse, + IssueValidationErrorResponse, +} from './issue-validation.js'; diff --git a/libs/types/src/issue-validation.ts b/libs/types/src/issue-validation.ts new file mode 100644 index 00000000..13fe9256 --- /dev/null +++ b/libs/types/src/issue-validation.ts @@ -0,0 +1,78 @@ +/** + * Issue Validation Types + * + * Types for validating GitHub issues against the codebase using Claude SDK. + */ + +/** + * Verdict from issue validation + */ +export type IssueValidationVerdict = 'valid' | 'invalid' | 'needs_clarification'; + +/** + * Confidence level of the validation + */ +export type IssueValidationConfidence = 'high' | 'medium' | 'low'; + +/** + * Complexity estimation for valid issues + */ +export type IssueComplexity = 'trivial' | 'simple' | 'moderate' | 'complex' | 'very_complex'; + +/** + * Issue data for validation (without projectPath) + * Used by UI when calling the validation API + */ +export interface IssueValidationInput { + issueNumber: number; + issueTitle: string; + issueBody: string; + issueLabels?: string[]; +} + +/** + * Full request payload for issue validation endpoint + * Includes projectPath for server-side handling + */ +export interface IssueValidationRequest extends IssueValidationInput { + projectPath: string; +} + +/** + * Result from Claude's issue validation analysis + */ +export interface IssueValidationResult { + /** Whether the issue is valid, invalid, or needs clarification */ + verdict: IssueValidationVerdict; + /** How confident the AI is in its assessment */ + confidence: IssueValidationConfidence; + /** Detailed explanation of the verdict */ + reasoning: string; + /** For bug reports: whether the bug was confirmed in the codebase */ + bugConfirmed?: boolean; + /** Files related to the issue found during analysis */ + relatedFiles?: string[]; + /** Suggested approach to fix or implement */ + suggestedFix?: string; + /** Information that's missing and needed for validation (when verdict = needs_clarification) */ + missingInfo?: string[]; + /** Estimated effort to address the issue */ + estimatedComplexity?: IssueComplexity; +} + +/** + * Successful response from validate-issue endpoint + */ +export interface IssueValidationResponse { + success: true; + issueNumber: number; + validation: IssueValidationResult; +} + +/** + * Error response from validate-issue endpoint + */ +export interface IssueValidationErrorResponse { + success: false; + error: string; +} From 5f0ecc8dd6c34176544a1722f504713386e7d096 Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 23 Dec 2025 16:57:29 +0100 Subject: [PATCH 02/18] feat: Enhance GitHub issue handling with assignees and linked PRs - Added support for assignees in GitHub issue data structure. - Implemented fetching of linked pull requests for open issues using the GitHub GraphQL API. - Updated UI to display assignees and linked PRs for selected issues. - Adjusted issue listing commands to include assignees in the fetched data. --- .../src/routes/github/routes/list-issues.ts | 143 +++++++++++++++++- .../routes/github/routes/validate-issue.ts | 2 +- .../components/views/github-issues-view.tsx | 122 ++++++++++++--- apps/ui/src/lib/electron.ts | 15 ++ 4 files changed, 260 insertions(+), 22 deletions(-) diff --git a/apps/server/src/routes/github/routes/list-issues.ts b/apps/server/src/routes/github/routes/list-issues.ts index 08f94135..581a4eaf 100644 --- a/apps/server/src/routes/github/routes/list-issues.ts +++ b/apps/server/src/routes/github/routes/list-issues.ts @@ -13,6 +13,19 @@ export interface GitHubLabel { export interface GitHubAuthor { login: string; + avatarUrl?: string; +} + +export interface GitHubAssignee { + login: string; + avatarUrl?: string; +} + +export interface LinkedPullRequest { + number: number; + title: string; + state: string; + url: string; } export interface GitHubIssue { @@ -24,6 +37,8 @@ export interface GitHubIssue { labels: GitHubLabel[]; url: string; body: string; + assignees: GitHubAssignee[]; + linkedPRs?: LinkedPullRequest[]; } export interface ListIssuesResult { @@ -33,6 +48,110 @@ export interface ListIssuesResult { error?: string; } +/** + * Fetch linked PRs for a list of issues using GitHub GraphQL API + */ +async function fetchLinkedPRs( + projectPath: string, + owner: string, + repo: string, + issueNumbers: number[] +): Promise> { + const linkedPRsMap = new Map(); + + if (issueNumbers.length === 0) { + return linkedPRsMap; + } + + // Build GraphQL query for batch fetching linked PRs + // We fetch up to 20 issues at a time to avoid query limits + const batchSize = 20; + for (let i = 0; i < issueNumbers.length; i += batchSize) { + const batch = issueNumbers.slice(i, i + batchSize); + + const issueQueries = batch + .map( + (num, idx) => ` + issue${idx}: issue(number: ${num}) { + number + timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } + } + } + } + } + }` + ) + .join('\n'); + + const query = `{ + repository(owner: "${owner}", name: "${repo}") { + ${issueQueries} + } + }`; + + try { + const { stdout } = await execAsync(`gh api graphql -f query='${query}'`, { + cwd: projectPath, + env: execEnv, + }); + + const response = JSON.parse(stdout); + const repoData = response?.data?.repository; + + if (repoData) { + batch.forEach((issueNum, idx) => { + const issueData = repoData[`issue${idx}`]; + if (issueData?.timelineItems?.nodes) { + const linkedPRs: LinkedPullRequest[] = []; + const seenPRs = new Set(); + + for (const node of issueData.timelineItems.nodes) { + const pr = node?.source || node?.subject; + if (pr?.number && !seenPRs.has(pr.number)) { + seenPRs.add(pr.number); + linkedPRs.push({ + number: pr.number, + title: pr.title, + state: pr.state.toLowerCase(), + url: pr.url, + }); + } + } + + if (linkedPRs.length > 0) { + linkedPRsMap.set(issueNum, linkedPRs); + } + } + }); + } + } catch { + // If GraphQL fails, continue without linked PRs + console.warn('Failed to fetch linked PRs via GraphQL'); + } + } + + return linkedPRsMap; +} + export function createListIssuesHandler() { return async (req: Request, res: Response): Promise => { try { @@ -53,17 +172,17 @@ export function createListIssuesHandler() { return; } - // Fetch open and closed issues in parallel + // Fetch open and closed issues in parallel (now including assignees) const [openResult, closedResult] = await Promise.all([ execAsync( - 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100', + 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100', { cwd: projectPath, env: execEnv, } ), execAsync( - 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50', + 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50', { cwd: projectPath, env: execEnv, @@ -77,6 +196,24 @@ export function createListIssuesHandler() { const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]'); const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]'); + // Fetch linked PRs for open issues (more relevant for active work) + if (remoteStatus.owner && remoteStatus.repo && openIssues.length > 0) { + const linkedPRsMap = await fetchLinkedPRs( + projectPath, + remoteStatus.owner, + remoteStatus.repo, + openIssues.map((i) => i.number) + ); + + // Attach linked PRs to issues + for (const issue of openIssues) { + const linkedPRs = linkedPRsMap.get(issue.number); + if (linkedPRs) { + issue.linkedPRs = linkedPRs; + } + } + } + res.json({ success: true, openIssues, diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 3bb7a66e..69af0418 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -76,7 +76,7 @@ export function createValidateIssueHandler() { // Create abort controller with 2 minute timeout for validation const abortController = new AbortController(); - const VALIDATION_TIMEOUT_MS = 120000; // 2 minutes + const VALIDATION_TIMEOUT_MS = 360000; // 6 minutes timeoutId = setTimeout(() => { logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`); abortController.abort(); diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index ef760833..b04397fd 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -8,6 +8,8 @@ import { Circle, X, Wand2, + GitPullRequest, + User, } from 'lucide-react'; import { getElectronAPI, @@ -369,7 +371,7 @@ export function GitHubIssuesView() { {/* Labels */} {selectedIssue.labels.length > 0 && ( -
+
{selectedIssue.labels.map((label) => ( )} + {/* Assignees */} + {selectedIssue.assignees && selectedIssue.assignees.length > 0 && ( +
+ + Assigned to: +
+ {selectedIssue.assignees.map((assignee) => ( + + {assignee.avatarUrl && ( + {assignee.login} + )} + {assignee.login} + + ))} +
+
+ )} + + {/* Linked Pull Requests */} + {selectedIssue.linkedPRs && selectedIssue.linkedPRs.length > 0 && ( +
+
+ + Linked Pull Requests +
+
+ {selectedIssue.linkedPRs.map((pr) => ( +
+
+ + {pr.state === 'open' + ? 'Open' + : pr.state === 'merged' + ? 'Merged' + : 'Closed'} + + #{pr.number} + {pr.title} +
+ +
+ ))} +
+
+ )} + {/* Body */} {selectedIssue.body ? ( {selectedIssue.body} @@ -454,23 +525,38 @@ function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: Is
- {issue.labels.length > 0 && ( -
- {issue.labels.map((label) => ( - - {label.name} - - ))} -
- )} +
+ {/* Labels */} + {issue.labels.map((label) => ( + + {label.name} + + ))} + + {/* Linked PR indicator */} + {issue.linkedPRs && issue.linkedPRs.length > 0 && ( + + + {issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''} + + )} + + {/* Assignee indicator */} + {issue.assignees && issue.assignees.length > 0 && ( + + + {issue.assignees.map((a) => a.login).join(', ')} + + )} +
+ {(() => { + const isValidating = validatingIssues.has(selectedIssue.number); + const cached = cachedValidations.get(selectedIssue.number); + const isStale = + cached && + (Date.now() - new Date(cached.validatedAt).getTime()) / (1000 * 60 * 60) > 24; + + if (isValidating) { + return ( + + ); + } + + if (cached && !isStale) { + return ( + <> + + + + ); + } + + if (cached && isStale) { + return ( + <> + + + + ); + } + + return ( + + ); + })()} + + {Icon && } + {confirmText} + + + + + ); +} diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 0ffbc10f..1da8dc3c 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -42,6 +42,7 @@ function getFeaturePriority(complexity: IssueComplexity | undefined): number { import { useAppStore } from '@/store/app-store'; import { Button } from '@/components/ui/button'; import { Markdown } from '@/components/ui/markdown'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { ValidationDialog } from './github-issues-view/validation-dialog'; @@ -60,6 +61,8 @@ export function GitHubIssuesView() { const [cachedValidations, setCachedValidations] = useState>( new Map() ); + // Track revalidation confirmation dialog + const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false); const audioRef = useRef(null); const { currentProject, validationModel, muteDoneSound } = useAppStore(); @@ -246,7 +249,12 @@ export function GitHubIssuesView() { }, []); const handleValidateIssue = useCallback( - async (issue: GitHubIssue, showDialog = true) => { + async ( + issue: GitHubIssue, + options: { showDialog?: boolean; forceRevalidate?: boolean } = {} + ) => { + const { showDialog = true, forceRevalidate = false } = options; + if (!currentProject?.path) { toast.error('No project selected'); return; @@ -258,9 +266,9 @@ export function GitHubIssuesView() { return; } - // Check for cached result - if fresh, show it directly + // Check for cached result - if fresh, show it directly (unless force revalidate) const cached = cachedValidations.get(issue.number); - if (cached && showDialog) { + if (cached && showDialog && !forceRevalidate) { // Check if validation is stale (older than 24 hours) const validatedAt = new Date(cached.validatedAt); const hoursSinceValidation = (Date.now() - validatedAt.getTime()) / (1000 * 60 * 60); @@ -568,7 +576,7 @@ export function GitHubIssuesView() { + )} + + ); +} diff --git a/apps/ui/src/components/ui/loading-state.tsx b/apps/ui/src/components/ui/loading-state.tsx new file mode 100644 index 00000000..9ae6ff3b --- /dev/null +++ b/apps/ui/src/components/ui/loading-state.tsx @@ -0,0 +1,17 @@ +import { Loader2 } from 'lucide-react'; + +interface LoadingStateProps { + /** Optional custom message to display below the spinner */ + message?: string; + /** Optional custom size class for the spinner (default: h-8 w-8) */ + size?: string; +} + +export function LoadingState({ message, size = 'h-8 w-8' }: LoadingStateProps) { + return ( +
+ + {message &&

{message}

} +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 3692d798..6081a2f6 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -1,81 +1,35 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { - CircleDot, - Loader2, - RefreshCw, - ExternalLink, - CheckCircle2, - Circle, - X, - Wand2, - GitPullRequest, - User, - CheckCircle, - Clock, - Sparkles, -} from 'lucide-react'; -import { - getElectronAPI, - GitHubIssue, - IssueValidationResult, - IssueComplexity, - IssueValidationEvent, - StoredValidation, -} from '@/lib/electron'; - -/** - * Map issue complexity to feature priority. - * Lower complexity issues get higher priority (1 = high, 2 = medium). - */ -function getFeaturePriority(complexity: IssueComplexity | undefined): number { - switch (complexity) { - case 'trivial': - case 'simple': - return 1; // High priority for easy wins - case 'moderate': - case 'complex': - case 'very_complex': - default: - return 2; // Medium priority for larger efforts - } -} +import { useState, useCallback, useMemo } from 'react'; +import { CircleDot, RefreshCw } from 'lucide-react'; +import { getElectronAPI, GitHubIssue, IssueValidationResult } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { Button } from '@/components/ui/button'; -import { Markdown } from '@/components/ui/markdown'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { LoadingState } from '@/components/ui/loading-state'; +import { ErrorState } from '@/components/ui/error-state'; import { cn, pathsEqual } from '@/lib/utils'; import { toast } from 'sonner'; -import { ValidationDialog } from './github-issues-view/validation-dialog'; +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'; export function GitHubIssuesView() { - const [openIssues, setOpenIssues] = useState([]); - const [closedIssues, setClosedIssues] = useState([]); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); const [selectedIssue, setSelectedIssue] = useState(null); - const [validatingIssues, setValidatingIssues] = useState>(new Set()); const [validationResult, setValidationResult] = useState(null); const [showValidationDialog, setShowValidationDialog] = useState(false); - // Track cached validations for display - const [cachedValidations, setCachedValidations] = useState>( - new Map() - ); - // Track revalidation confirmation dialog const [showRevalidateConfirm, setShowRevalidateConfirm] = useState(false); - const audioRef = useRef(null); - // Refs for stable event handler (avoids re-subscribing on state changes) - const selectedIssueRef = useRef(null); - const showValidationDialogRef = useRef(false); - const { - currentProject, - validationModel, - muteDoneSound, - defaultAIProfileId, - aiProfiles, - getCurrentWorktree, - worktreesByProject, - } = useAppStore(); + + const { currentProject, defaultAIProfileId, aiProfiles, getCurrentWorktree, worktreesByProject } = + useAppStore(); + + const { openIssues, closedIssues, loading, refreshing, error, refresh } = useGithubIssues(); + + const { validatingIssues, cachedValidations, handleValidateIssue, handleViewCachedValidation } = + useIssueValidation({ + selectedIssue, + showValidationDialog, + onValidationResultChange: setValidationResult, + onShowValidationDialogChange: setShowValidationDialog, + }); // Get default AI profile for task creation const defaultProfile = useMemo(() => { @@ -98,328 +52,11 @@ export function GitHubIssuesView() { return selectedWorktree?.branch || worktrees.find((w) => w.isMain)?.branch || ''; }, [currentProject?.path, getCurrentWorktree, worktreesByProject]); - const fetchIssues = useCallback(async () => { - if (!currentProject?.path) { - setError('No project selected'); - setLoading(false); - return; - } - - try { - setError(null); - const api = getElectronAPI(); - if (api.github) { - const result = await api.github.listIssues(currentProject.path); - if (result.success) { - setOpenIssues(result.openIssues || []); - setClosedIssues(result.closedIssues || []); - } else { - setError(result.error || 'Failed to fetch issues'); - } - } - } catch (err) { - console.error('[GitHubIssuesView] Error fetching issues:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch issues'); - } finally { - setLoading(false); - setRefreshing(false); - } - }, [currentProject?.path]); - - useEffect(() => { - fetchIssues(); - }, [fetchIssues]); - - // Load cached validations on mount - useEffect(() => { - let isMounted = true; - - const loadCachedValidations = async () => { - if (!currentProject?.path) return; - - try { - const api = getElectronAPI(); - if (api.github?.getValidations) { - const result = await api.github.getValidations(currentProject.path); - if (isMounted && result.success && result.validations) { - const map = new Map(); - for (const v of result.validations) { - map.set(v.issueNumber, v); - } - setCachedValidations(map); - } - } - } catch (err) { - if (isMounted) { - console.error('[GitHubIssuesView] Failed to load cached validations:', err); - } - } - }; - - loadCachedValidations(); - - return () => { - isMounted = false; - }; - }, [currentProject?.path]); - - // Load running validations on mount (restore validatingIssues state) - useEffect(() => { - let isMounted = true; - - const loadRunningValidations = async () => { - if (!currentProject?.path) return; - - try { - const api = getElectronAPI(); - if (api.github?.getValidationStatus) { - const result = await api.github.getValidationStatus(currentProject.path); - if (isMounted && result.success && result.runningIssues) { - setValidatingIssues(new Set(result.runningIssues)); - } - } - } catch (err) { - if (isMounted) { - console.error('[GitHubIssuesView] Failed to load running validations:', err); - } - } - }; - - loadRunningValidations(); - - return () => { - isMounted = false; - }; - }, [currentProject?.path]); - - // Keep refs in sync with state for stable event handler - useEffect(() => { - selectedIssueRef.current = selectedIssue; - }, [selectedIssue]); - - useEffect(() => { - showValidationDialogRef.current = showValidationDialog; - }, [showValidationDialog]); - - // Subscribe to validation events - useEffect(() => { - const api = getElectronAPI(); - if (!api.github?.onValidationEvent) return; - - const handleValidationEvent = (event: IssueValidationEvent) => { - // Only handle events for current project - if (event.projectPath !== currentProject?.path) return; - - switch (event.type) { - case 'issue_validation_start': - setValidatingIssues((prev) => new Set([...prev, event.issueNumber])); - break; - - case 'issue_validation_complete': - setValidatingIssues((prev) => { - const next = new Set(prev); - next.delete(event.issueNumber); - return next; - }); - - // Update cached validations (use event.model to avoid stale closure race condition) - setCachedValidations((prev) => { - const next = new Map(prev); - next.set(event.issueNumber, { - issueNumber: event.issueNumber, - issueTitle: event.issueTitle, - validatedAt: new Date().toISOString(), - model: event.model, - result: event.result, - }); - return next; - }); - - // Show toast notification - toast.success(`Issue #${event.issueNumber} validated: ${event.result.verdict}`, { - description: - event.result.verdict === 'valid' - ? 'Issue is ready to be converted to a task' - : event.result.verdict === 'invalid' - ? 'Issue may have problems' - : 'Issue needs clarification', - }); - - // Play audio notification (if not muted) - if (!muteDoneSound) { - try { - if (!audioRef.current) { - audioRef.current = new Audio('/sounds/ding.mp3'); - } - audioRef.current.play().catch(() => { - // Audio play might fail due to browser restrictions - }); - } catch { - // Ignore audio errors - } - } - - // If validation dialog is open for this issue, update the result - if ( - selectedIssueRef.current?.number === event.issueNumber && - showValidationDialogRef.current - ) { - setValidationResult(event.result); - } - break; - - case 'issue_validation_error': - setValidatingIssues((prev) => { - const next = new Set(prev); - next.delete(event.issueNumber); - return next; - }); - toast.error(`Validation failed for issue #${event.issueNumber}`, { - description: event.error, - }); - if ( - selectedIssueRef.current?.number === event.issueNumber && - showValidationDialogRef.current - ) { - setShowValidationDialog(false); - } - break; - } - }; - - const unsubscribe = api.github.onValidationEvent(handleValidationEvent); - return () => unsubscribe(); - }, [currentProject?.path, muteDoneSound]); - - // Cleanup audio element on unmount to prevent memory leaks - useEffect(() => { - return () => { - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current = null; - } - }; - }, []); - - const handleRefresh = useCallback(() => { - setRefreshing(true); - fetchIssues(); - }, [fetchIssues]); - const handleOpenInGitHub = useCallback((url: string) => { const api = getElectronAPI(); api.openExternalLink(url); }, []); - const handleValidateIssue = useCallback( - async ( - issue: GitHubIssue, - options: { showDialog?: boolean; forceRevalidate?: boolean } = {} - ) => { - const { showDialog = true, forceRevalidate = false } = options; - - if (!currentProject?.path) { - toast.error('No project selected'); - return; - } - - // Check if already validating this issue - if (validatingIssues.has(issue.number)) { - toast.info(`Validation already in progress for issue #${issue.number}`); - return; - } - - // Check for cached result - if fresh, show it directly (unless force revalidate) - const cached = cachedValidations.get(issue.number); - if (cached && showDialog && !forceRevalidate) { - // Check if validation is stale (older than 24 hours) - const validatedAt = new Date(cached.validatedAt); - const hoursSinceValidation = (Date.now() - validatedAt.getTime()) / (1000 * 60 * 60); - const isStale = hoursSinceValidation > 24; - - if (!isStale) { - // Show cached result directly - setValidationResult(cached.result); - setShowValidationDialog(true); - return; - } - } - - // Start async validation - setValidationResult(null); - if (showDialog) { - setShowValidationDialog(true); - } - - try { - const api = getElectronAPI(); - if (api.github?.validateIssue) { - const result = await api.github.validateIssue( - currentProject.path, - { - issueNumber: issue.number, - issueTitle: issue.title, - issueBody: issue.body || '', - issueLabels: issue.labels.map((l) => l.name), - }, - validationModel - ); - - if (!result.success) { - toast.error(result.error || 'Failed to start validation'); - if (showDialog) { - setShowValidationDialog(false); - } - } - // On success, the result will come through the event stream - } - } catch (err) { - console.error('[GitHubIssuesView] Validation error:', err); - toast.error(err instanceof Error ? err.message : 'Failed to validate issue'); - if (showDialog) { - setShowValidationDialog(false); - } - } - }, - [currentProject?.path, validatingIssues, cachedValidations, validationModel] - ); - - // View cached validation result - const handleViewCachedValidation = useCallback( - async (issue: GitHubIssue) => { - const cached = cachedValidations.get(issue.number); - if (cached) { - setValidationResult(cached.result); - setShowValidationDialog(true); - - // Mark as viewed if not already viewed - if (!cached.viewedAt && currentProject?.path) { - try { - const api = getElectronAPI(); - if (api.github?.markValidationViewed) { - await api.github.markValidationViewed(currentProject.path, issue.number); - // Update local state - setCachedValidations((prev) => { - const next = new Map(prev); - const updated = prev.get(issue.number); - if (updated) { - next.set(issue.number, { - ...updated, - viewedAt: new Date().toISOString(), - }); - } - return next; - }); - } - } catch (err) { - console.error('[GitHubIssuesView] Failed to mark validation as viewed:', err); - } - } - } - }, - [cachedValidations, currentProject?.path] - ); - const handleConvertToTask = useCallback( async (issue: GitHubIssue, validation: IssueValidationResult) => { if (!currentProject?.path) { @@ -478,37 +115,12 @@ export function GitHubIssuesView() { [currentProject?.path, defaultProfile, currentBranch] ); - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); - }; - if (loading) { - return ( -
- -
- ); + return ; } if (error) { - return ( -
-
- -
-

Failed to Load Issues

-

{error}

- -
- ); + return ; } const totalIssues = openIssues.length + closedIssues.length; @@ -523,24 +135,12 @@ export function GitHubIssuesView() { )} > {/* Header */} -
-
-
- -
-
-

Issues

-

- {totalIssues === 0 - ? 'No issues found' - : `${openIssues.length} open, ${closedIssues.length} closed`} -

-
-
- -
+ {/* Issues List */}
@@ -595,239 +195,17 @@ export function GitHubIssuesView() { {/* Issue Detail Panel */} {selectedIssue && ( -
- {/* Detail Header */} -
-
- {selectedIssue.state === 'OPEN' ? ( - - ) : ( - - )} - - #{selectedIssue.number} {selectedIssue.title} - -
-
- {(() => { - const isValidating = validatingIssues.has(selectedIssue.number); - const cached = cachedValidations.get(selectedIssue.number); - const isStale = - cached && - (Date.now() - new Date(cached.validatedAt).getTime()) / (1000 * 60 * 60) > 24; - - if (isValidating) { - return ( - - ); - } - - if (cached && !isStale) { - return ( - <> - - - - ); - } - - if (cached && isStale) { - return ( - <> - - - - ); - } - - return ( - - ); - })()} - - -
-
- - {/* Issue Detail Content */} -
- {/* Title */} -

{selectedIssue.title}

- - {/* Meta info */} -
- - {selectedIssue.state === 'OPEN' ? 'Open' : 'Closed'} - - - #{selectedIssue.number} opened {formatDate(selectedIssue.createdAt)} by{' '} - {selectedIssue.author.login} - -
- - {/* Labels */} - {selectedIssue.labels.length > 0 && ( -
- {selectedIssue.labels.map((label) => ( - - {label.name} - - ))} -
- )} - - {/* Assignees */} - {selectedIssue.assignees && selectedIssue.assignees.length > 0 && ( -
- - Assigned to: -
- {selectedIssue.assignees.map((assignee) => ( - - {assignee.avatarUrl && ( - {assignee.login} - )} - {assignee.login} - - ))} -
-
- )} - - {/* Linked Pull Requests */} - {selectedIssue.linkedPRs && selectedIssue.linkedPRs.length > 0 && ( -
-
- - Linked Pull Requests -
-
- {selectedIssue.linkedPRs.map((pr) => ( -
-
- - {pr.state === 'open' - ? 'Open' - : pr.state === 'merged' - ? 'Merged' - : 'Closed'} - - #{pr.number} - {pr.title} -
- -
- ))} -
-
- )} - - {/* Body */} - {selectedIssue.body ? ( - {selectedIssue.body} - ) : ( -

No description provided.

- )} - - {/* Open in GitHub CTA */} -
-

- View comments, add reactions, and more on GitHub. -

- -
-
-
+ setSelectedIssue(null)} + onShowRevalidateConfirm={() => setShowRevalidateConfirm(true)} + formatDate={formatDate} + /> )} {/* Validation Dialog */} @@ -858,134 +236,3 @@ export function GitHubIssuesView() {
); } - -interface IssueRowProps { - issue: GitHubIssue; - isSelected: boolean; - onClick: () => void; - onOpenExternal: () => void; - formatDate: (date: string) => string; - /** Cached validation for this issue (if any) */ - cachedValidation?: StoredValidation | null; - /** Whether validation is currently running for this issue */ - isValidating?: boolean; -} - -function IssueRow({ - issue, - isSelected, - onClick, - onOpenExternal, - formatDate, - cachedValidation, - isValidating, -}: IssueRowProps) { - // Check if validation exists and calculate staleness - const validationHoursSince = cachedValidation - ? (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60) - : null; - const isValidationStale = validationHoursSince !== null && validationHoursSince > 24; - - // Check if validation is unviewed (exists, not stale, not viewed) - const hasUnviewedValidation = - cachedValidation && !cachedValidation.viewedAt && !isValidationStale; - - // Check if validation has been viewed (exists and was viewed) - const hasViewedValidation = cachedValidation && cachedValidation.viewedAt && !isValidationStale; - return ( -
- {issue.state === 'OPEN' ? ( - - ) : ( - - )} - -
-
- {issue.title} -
- -
- - #{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login} - -
- -
- {/* Labels */} - {issue.labels.map((label) => ( - - {label.name} - - ))} - - {/* Linked PR indicator */} - {issue.linkedPRs && issue.linkedPRs.length > 0 && ( - - - {issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''} - - )} - - {/* Assignee indicator */} - {issue.assignees && issue.assignees.length > 0 && ( - - - {issue.assignees.map((a) => a.login).join(', ')} - - )} - - {/* Validating indicator */} - {isValidating && ( - - - Analyzing... - - )} - - {/* Unviewed validation indicator */} - {!isValidating && hasUnviewedValidation && ( - - - Analysis Ready - - )} - - {/* Viewed validation indicator */} - {!isValidating && hasViewedValidation && ( - - - Validated - - )} -
-
- - -
- ); -} 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 new file mode 100644 index 00000000..b3af9f84 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/index.ts @@ -0,0 +1,3 @@ +export { IssueRow } from './issue-row'; +export { IssueDetailPanel } from './issue-detail-panel'; +export { IssuesListHeader } from './issues-list-header'; 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 new file mode 100644 index 00000000..7969da38 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/issue-detail-panel.tsx @@ -0,0 +1,242 @@ +import { + Circle, + CheckCircle2, + X, + Wand2, + ExternalLink, + Loader2, + CheckCircle, + Clock, + GitPullRequest, + User, + RefreshCw, +} from 'lucide-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'; + +export function IssueDetailPanel({ + issue, + validatingIssues, + cachedValidations, + onValidateIssue, + onViewCachedValidation, + onOpenInGitHub, + onClose, + onShowRevalidateConfirm, + formatDate, +}: IssueDetailPanelProps) { + const isValidating = validatingIssues.has(issue.number); + const cached = cachedValidations.get(issue.number); + const isStale = cached ? isValidationStale(cached.validatedAt) : false; + + return ( +
+ {/* Detail Header */} +
+
+ {issue.state === 'OPEN' ? ( + + ) : ( + + )} + + #{issue.number} {issue.title} + +
+
+ {(() => { + if (isValidating) { + return ( + + ); + } + + if (cached && !isStale) { + return ( + <> + + + + ); + } + + if (cached && isStale) { + return ( + <> + + + + ); + } + + return ( + + ); + })()} + + +
+
+ + {/* Issue Detail Content */} +
+ {/* Title */} +

{issue.title}

+ + {/* Meta info */} +
+ + {issue.state === 'OPEN' ? 'Open' : 'Closed'} + + + #{issue.number} opened {formatDate(issue.createdAt)} by{' '} + {issue.author.login} + +
+ + {/* Labels */} + {issue.labels.length > 0 && ( +
+ {issue.labels.map((label) => ( + + {label.name} + + ))} +
+ )} + + {/* Assignees */} + {issue.assignees && issue.assignees.length > 0 && ( +
+ + Assigned to: +
+ {issue.assignees.map((assignee) => ( + + {assignee.avatarUrl && ( + {assignee.login} + )} + {assignee.login} + + ))} +
+
+ )} + + {/* Linked Pull Requests */} + {issue.linkedPRs && issue.linkedPRs.length > 0 && ( +
+
+ + Linked Pull Requests +
+
+ {issue.linkedPRs.map((pr) => ( +
+
+ + {pr.state === 'open' ? 'Open' : pr.state === 'merged' ? 'Merged' : 'Closed'} + + #{pr.number} + {pr.title} +
+ +
+ ))} +
+
+ )} + + {/* Body */} + {issue.body ? ( + {issue.body} + ) : ( +

No description provided.

+ )} + + {/* Open in GitHub CTA */} +
+

+ View comments, add reactions, and more on GitHub. +

+ +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx new file mode 100644 index 00000000..bf6496f1 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/issue-row.tsx @@ -0,0 +1,136 @@ +import { + Circle, + CheckCircle2, + ExternalLink, + Loader2, + CheckCircle, + Sparkles, + GitPullRequest, + User, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { IssueRowProps } from '../types'; +import { isValidationStale } from '../utils'; + +export function IssueRow({ + issue, + isSelected, + onClick, + onOpenExternal, + formatDate, + cachedValidation, + isValidating, +}: IssueRowProps) { + // Check if validation exists and calculate staleness + const validationHoursSince = cachedValidation + ? (Date.now() - new Date(cachedValidation.validatedAt).getTime()) / (1000 * 60 * 60) + : null; + const isValidationStaleValue = + validationHoursSince !== null && isValidationStale(cachedValidation!.validatedAt); + + // Check if validation is unviewed (exists, not stale, not viewed) + const hasUnviewedValidation = + cachedValidation && !cachedValidation.viewedAt && !isValidationStaleValue; + + // Check if validation has been viewed (exists and was viewed) + const hasViewedValidation = + cachedValidation && cachedValidation.viewedAt && !isValidationStaleValue; + + return ( +
+ {issue.state === 'OPEN' ? ( + + ) : ( + + )} + +
+
+ {issue.title} +
+ +
+ + #{issue.number} opened {formatDate(issue.createdAt)} by {issue.author.login} + +
+ +
+ {/* Labels */} + {issue.labels.map((label) => ( + + {label.name} + + ))} + + {/* Linked PR indicator */} + {issue.linkedPRs && issue.linkedPRs.length > 0 && ( + + + {issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''} + + )} + + {/* Assignee indicator */} + {issue.assignees && issue.assignees.length > 0 && ( + + + {issue.assignees.map((a) => a.login).join(', ')} + + )} + + {/* Validating indicator */} + {isValidating && ( + + + Analyzing... + + )} + + {/* Unviewed validation indicator */} + {!isValidating && hasUnviewedValidation && ( + + + Analysis Ready + + )} + + {/* Viewed validation indicator */} + {!isValidating && hasViewedValidation && ( + + + Validated + + )} +
+
+ + +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx new file mode 100644 index 00000000..5529b30c --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/components/issues-list-header.tsx @@ -0,0 +1,38 @@ +import { CircleDot, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface IssuesListHeaderProps { + openCount: number; + closedCount: number; + refreshing: boolean; + onRefresh: () => void; +} + +export function IssuesListHeader({ + openCount, + closedCount, + refreshing, + onRefresh, +}: IssuesListHeaderProps) { + const totalIssues = openCount + closedCount; + + return ( +
+
+
+ +
+
+

Issues

+

+ {totalIssues === 0 ? 'No issues found' : `${openCount} open, ${closedCount} closed`} +

+
+
+ +
+ ); +} diff --git a/apps/ui/src/components/views/github-issues-view/constants.ts b/apps/ui/src/components/views/github-issues-view/constants.ts new file mode 100644 index 00000000..22a6785a --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/constants.ts @@ -0,0 +1 @@ +export const VALIDATION_STALENESS_HOURS = 24; diff --git a/apps/ui/src/components/views/github-issues-view/dialogs/index.ts b/apps/ui/src/components/views/github-issues-view/dialogs/index.ts new file mode 100644 index 00000000..886b09b2 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/dialogs/index.ts @@ -0,0 +1 @@ +export { ValidationDialog } from './validation-dialog'; diff --git a/apps/ui/src/components/views/github-issues-view/validation-dialog.tsx b/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx similarity index 99% rename from apps/ui/src/components/views/github-issues-view/validation-dialog.tsx rename to apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx index 1f8374e9..cc989c8c 100644 --- a/apps/ui/src/components/views/github-issues-view/validation-dialog.tsx +++ b/apps/ui/src/components/views/github-issues-view/dialogs/validation-dialog.tsx @@ -152,7 +152,7 @@ export function ValidationDialog({ {/* Bug Confirmed Badge */} {validationResult.bugConfirmed && (
- + Bug Confirmed in Codebase
)} 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 new file mode 100644 index 00000000..c3417416 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/index.ts @@ -0,0 +1,2 @@ +export { useGithubIssues } from './use-github-issues'; +export { useIssueValidation } from './use-issue-validation'; diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts new file mode 100644 index 00000000..27e33488 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts @@ -0,0 +1,58 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getElectronAPI, GitHubIssue } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; + +export function useGithubIssues() { + const { currentProject } = useAppStore(); + const [openIssues, setOpenIssues] = useState([]); + const [closedIssues, setClosedIssues] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + + const fetchIssues = useCallback(async () => { + if (!currentProject?.path) { + setError('No project selected'); + setLoading(false); + return; + } + + try { + setError(null); + const api = getElectronAPI(); + if (api.github) { + const result = await api.github.listIssues(currentProject.path); + if (result.success) { + setOpenIssues(result.openIssues || []); + setClosedIssues(result.closedIssues || []); + } else { + setError(result.error || 'Failed to fetch issues'); + } + } + } catch (err) { + console.error('[GitHubIssuesView] Error fetching issues:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch issues'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [currentProject?.path]); + + useEffect(() => { + fetchIssues(); + }, [fetchIssues]); + + const refresh = useCallback(() => { + setRefreshing(true); + fetchIssues(); + }, [fetchIssues]); + + return { + openIssues, + closedIssues, + loading, + refreshing, + error, + 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 new file mode 100644 index 00000000..25665757 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-issue-validation.ts @@ -0,0 +1,330 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { + getElectronAPI, + GitHubIssue, + IssueValidationResult, + IssueValidationEvent, + StoredValidation, +} from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; +import { toast } from 'sonner'; +import { isValidationStale } from '../utils'; + +interface UseIssueValidationOptions { + selectedIssue: GitHubIssue | null; + showValidationDialog: boolean; + onValidationResultChange: (result: IssueValidationResult | null) => void; + onShowValidationDialogChange: (show: boolean) => void; +} + +export function useIssueValidation({ + selectedIssue, + showValidationDialog, + onValidationResultChange, + onShowValidationDialogChange, +}: UseIssueValidationOptions) { + const { currentProject, validationModel, muteDoneSound } = useAppStore(); + const [validatingIssues, setValidatingIssues] = useState>(new Set()); + const [cachedValidations, setCachedValidations] = useState>( + new Map() + ); + const audioRef = useRef(null); + // Refs for stable event handler (avoids re-subscribing on state changes) + const selectedIssueRef = useRef(null); + const showValidationDialogRef = useRef(false); + + // Keep refs in sync with state for stable event handler + useEffect(() => { + selectedIssueRef.current = selectedIssue; + }, [selectedIssue]); + + useEffect(() => { + showValidationDialogRef.current = showValidationDialog; + }, [showValidationDialog]); + + // Load cached validations on mount + useEffect(() => { + let isMounted = true; + + const loadCachedValidations = async () => { + if (!currentProject?.path) return; + + try { + const api = getElectronAPI(); + if (api.github?.getValidations) { + const result = await api.github.getValidations(currentProject.path); + if (isMounted && result.success && result.validations) { + const map = new Map(); + for (const v of result.validations) { + map.set(v.issueNumber, v); + } + setCachedValidations(map); + } + } + } catch (err) { + if (isMounted) { + console.error('[GitHubIssuesView] Failed to load cached validations:', err); + } + } + }; + + loadCachedValidations(); + + return () => { + isMounted = false; + }; + }, [currentProject?.path]); + + // Load running validations on mount (restore validatingIssues state) + useEffect(() => { + let isMounted = true; + + const loadRunningValidations = async () => { + if (!currentProject?.path) return; + + try { + const api = getElectronAPI(); + if (api.github?.getValidationStatus) { + const result = await api.github.getValidationStatus(currentProject.path); + if (isMounted && result.success && result.runningIssues) { + setValidatingIssues(new Set(result.runningIssues)); + } + } + } catch (err) { + if (isMounted) { + console.error('[GitHubIssuesView] Failed to load running validations:', err); + } + } + }; + + loadRunningValidations(); + + return () => { + isMounted = false; + }; + }, [currentProject?.path]); + + // Subscribe to validation events + useEffect(() => { + const api = getElectronAPI(); + if (!api.github?.onValidationEvent) return; + + const handleValidationEvent = (event: IssueValidationEvent) => { + // Only handle events for current project + if (event.projectPath !== currentProject?.path) return; + + switch (event.type) { + case 'issue_validation_start': + setValidatingIssues((prev) => new Set([...prev, event.issueNumber])); + break; + + case 'issue_validation_complete': + setValidatingIssues((prev) => { + const next = new Set(prev); + next.delete(event.issueNumber); + return next; + }); + + // Update cached validations (use event.model to avoid stale closure race condition) + setCachedValidations((prev) => { + const next = new Map(prev); + next.set(event.issueNumber, { + issueNumber: event.issueNumber, + issueTitle: event.issueTitle, + validatedAt: new Date().toISOString(), + model: event.model, + result: event.result, + }); + return next; + }); + + // Show toast notification + toast.success(`Issue #${event.issueNumber} validated: ${event.result.verdict}`, { + description: + event.result.verdict === 'valid' + ? 'Issue is ready to be converted to a task' + : event.result.verdict === 'invalid' + ? 'Issue may have problems' + : 'Issue needs clarification', + }); + + // Play audio notification (if not muted) + if (!muteDoneSound) { + try { + if (!audioRef.current) { + audioRef.current = new Audio('/sounds/ding.mp3'); + } + audioRef.current.play().catch(() => { + // Audio play might fail due to browser restrictions + }); + } catch { + // Ignore audio errors + } + } + + // If validation dialog is open for this issue, update the result + if ( + selectedIssueRef.current?.number === event.issueNumber && + showValidationDialogRef.current + ) { + onValidationResultChange(event.result); + } + break; + + case 'issue_validation_error': + setValidatingIssues((prev) => { + const next = new Set(prev); + next.delete(event.issueNumber); + return next; + }); + toast.error(`Validation failed for issue #${event.issueNumber}`, { + description: event.error, + }); + if ( + selectedIssueRef.current?.number === event.issueNumber && + showValidationDialogRef.current + ) { + onShowValidationDialogChange(false); + } + break; + } + }; + + const unsubscribe = api.github.onValidationEvent(handleValidationEvent); + return () => unsubscribe(); + }, [currentProject?.path, muteDoneSound, onValidationResultChange, onShowValidationDialogChange]); + + // Cleanup audio element on unmount to prevent memory leaks + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current = null; + } + }; + }, []); + + const handleValidateIssue = useCallback( + async ( + issue: GitHubIssue, + options: { showDialog?: boolean; forceRevalidate?: boolean } = {} + ) => { + const { showDialog = true, forceRevalidate = false } = options; + + if (!currentProject?.path) { + toast.error('No project selected'); + return; + } + + // Check if already validating this issue + if (validatingIssues.has(issue.number)) { + toast.info(`Validation already in progress for issue #${issue.number}`); + return; + } + + // Check for cached result - if fresh, show it directly (unless force revalidate) + const cached = cachedValidations.get(issue.number); + if (cached && showDialog && !forceRevalidate) { + // Check if validation is stale + if (!isValidationStale(cached.validatedAt)) { + // Show cached result directly + onValidationResultChange(cached.result); + onShowValidationDialogChange(true); + return; + } + } + + // Start async validation + onValidationResultChange(null); + if (showDialog) { + onShowValidationDialogChange(true); + } + + try { + const api = getElectronAPI(); + if (api.github?.validateIssue) { + const result = await api.github.validateIssue( + currentProject.path, + { + issueNumber: issue.number, + issueTitle: issue.title, + issueBody: issue.body || '', + issueLabels: issue.labels.map((l) => l.name), + }, + validationModel + ); + + if (!result.success) { + toast.error(result.error || 'Failed to start validation'); + if (showDialog) { + onShowValidationDialogChange(false); + } + } + // On success, the result will come through the event stream + } + } catch (err) { + console.error('[GitHubIssuesView] Validation error:', err); + toast.error(err instanceof Error ? err.message : 'Failed to validate issue'); + if (showDialog) { + onShowValidationDialogChange(false); + } + } + }, + [ + currentProject?.path, + validatingIssues, + cachedValidations, + validationModel, + onValidationResultChange, + onShowValidationDialogChange, + ] + ); + + // View cached validation result + const handleViewCachedValidation = useCallback( + async (issue: GitHubIssue) => { + const cached = cachedValidations.get(issue.number); + if (cached) { + onValidationResultChange(cached.result); + onShowValidationDialogChange(true); + + // Mark as viewed if not already viewed + if (!cached.viewedAt && currentProject?.path) { + try { + const api = getElectronAPI(); + if (api.github?.markValidationViewed) { + await api.github.markValidationViewed(currentProject.path, issue.number); + // Update local state + setCachedValidations((prev) => { + const next = new Map(prev); + const updated = prev.get(issue.number); + if (updated) { + next.set(issue.number, { + ...updated, + viewedAt: new Date().toISOString(), + }); + } + return next; + }); + } + } catch (err) { + console.error('[GitHubIssuesView] Failed to mark validation as viewed:', err); + } + } + } + }, + [ + cachedValidations, + currentProject?.path, + onValidationResultChange, + onShowValidationDialogChange, + ] + ); + + return { + validatingIssues, + cachedValidations, + handleValidateIssue, + handleViewCachedValidation, + }; +} diff --git a/apps/ui/src/components/views/github-issues-view/types.ts b/apps/ui/src/components/views/github-issues-view/types.ts new file mode 100644 index 00000000..9fce6d53 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/types.ts @@ -0,0 +1,28 @@ +import type { GitHubIssue, StoredValidation } from '@/lib/electron'; + +export interface IssueRowProps { + issue: GitHubIssue; + isSelected: boolean; + onClick: () => void; + onOpenExternal: () => void; + formatDate: (date: string) => string; + /** Cached validation for this issue (if any) */ + cachedValidation?: StoredValidation | null; + /** Whether validation is currently running for this issue */ + isValidating?: boolean; +} + +export interface IssueDetailPanelProps { + issue: GitHubIssue; + validatingIssues: Set; + cachedValidations: Map; + onValidateIssue: ( + issue: GitHubIssue, + options?: { showDialog?: boolean; forceRevalidate?: boolean } + ) => Promise; + onViewCachedValidation: (issue: GitHubIssue) => Promise; + onOpenInGitHub: (url: string) => void; + onClose: () => void; + onShowRevalidateConfirm: () => void; + formatDate: (date: string) => string; +} diff --git a/apps/ui/src/components/views/github-issues-view/utils.ts b/apps/ui/src/components/views/github-issues-view/utils.ts new file mode 100644 index 00000000..ad313317 --- /dev/null +++ b/apps/ui/src/components/views/github-issues-view/utils.ts @@ -0,0 +1,33 @@ +import type { IssueComplexity } from '@/lib/electron'; +import { VALIDATION_STALENESS_HOURS } from './constants'; + +/** + * Map issue complexity to feature priority. + * Lower complexity issues get higher priority (1 = high, 2 = medium). + */ +export function getFeaturePriority(complexity: IssueComplexity | undefined): number { + switch (complexity) { + case 'trivial': + case 'simple': + return 1; // High priority for easy wins + case 'moderate': + case 'complex': + case 'very_complex': + default: + return 2; // Medium priority for larger efforts + } +} + +export function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +export function isValidationStale(validatedAt: string): boolean { + const hoursSinceValidation = (Date.now() - new Date(validatedAt).getTime()) / (1000 * 60 * 60); + return hoursSinceValidation > VALIDATION_STALENESS_HOURS; +} From a85e1aaa89408485f7eeb1cfd8a6ae8bd257273e Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 24 Dec 2025 02:30:36 +0100 Subject: [PATCH 17/18] refactor: Simplify validation handling in GitHubIssuesView - Removed the isValidating prop from GitHubIssuesView and ValidationDialog components to streamline validation logic. - Updated handleValidateIssue function to eliminate unnecessary dialog options, focusing on background validation notifications. - Enhanced user feedback by notifying users when validation starts, improving overall experience during issue analysis. --- .../components/views/github-issues-view.tsx | 1 - .../dialogs/validation-dialog.tsx | 10 +----- .../hooks/use-issue-validation.ts | 35 ++++++------------- 3 files changed, 12 insertions(+), 34 deletions(-) diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index 6081a2f6..10876e38 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -214,7 +214,6 @@ export function GitHubIssuesView() { onOpenChange={setShowValidationDialog} issue={selectedIssue} validationResult={validationResult} - isValidating={selectedIssue ? validatingIssues.has(selectedIssue.number) : false} onConvertToTask={handleConvertToTask} /> 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 cc989c8c..fba1a9ea 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 @@ -15,7 +15,6 @@ import { FileCode, Lightbulb, AlertTriangle, - Loader2, Plus, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -32,7 +31,6 @@ interface ValidationDialogProps { onOpenChange: (open: boolean) => void; issue: GitHubIssue | null; validationResult: IssueValidationResult | null; - isValidating: boolean; onConvertToTask?: (issue: GitHubIssue, validation: IssueValidationResult) => void; } @@ -79,7 +77,6 @@ export function ValidationDialog({ onOpenChange, issue, validationResult, - isValidating, onConvertToTask, }: ValidationDialogProps) { if (!issue) return null; @@ -101,12 +98,7 @@ export function ValidationDialog({ - {isValidating ? ( -
- -

Analyzing codebase to validate issue...

-
- ) : validationResult ? ( + {validationResult ? (
{/* Verdict Badge */}
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 25665757..136185c5 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 @@ -205,11 +205,8 @@ export function useIssueValidation({ }, []); const handleValidateIssue = useCallback( - async ( - issue: GitHubIssue, - options: { showDialog?: boolean; forceRevalidate?: boolean } = {} - ) => { - const { showDialog = true, forceRevalidate = false } = options; + async (issue: GitHubIssue, options: { forceRevalidate?: boolean } = {}) => { + const { forceRevalidate = false } = options; if (!currentProject?.path) { toast.error('No project selected'); @@ -224,21 +221,17 @@ export function useIssueValidation({ // Check for cached result - if fresh, show it directly (unless force revalidate) const cached = cachedValidations.get(issue.number); - if (cached && showDialog && !forceRevalidate) { - // Check if validation is stale - if (!isValidationStale(cached.validatedAt)) { - // Show cached result directly - onValidationResultChange(cached.result); - onShowValidationDialogChange(true); - return; - } + if (cached && !forceRevalidate && !isValidationStale(cached.validatedAt)) { + // Show cached result directly + onValidationResultChange(cached.result); + onShowValidationDialogChange(true); + return; } - // Start async validation - onValidationResultChange(null); - if (showDialog) { - onShowValidationDialogChange(true); - } + // Start async validation in background (no dialog - user will see badge when done) + toast.info(`Starting validation for issue #${issue.number}`, { + description: 'You will be notified when the analysis is complete', + }); try { const api = getElectronAPI(); @@ -256,18 +249,12 @@ export function useIssueValidation({ if (!result.success) { toast.error(result.error || 'Failed to start validation'); - if (showDialog) { - onShowValidationDialogChange(false); - } } // On success, the result will come through the event stream } } catch (err) { console.error('[GitHubIssuesView] Validation error:', err); toast.error(err instanceof Error ? err.message : 'Failed to validate issue'); - if (showDialog) { - onShowValidationDialogChange(false); - } } }, [ From 38addacf1e07e54e81576044a97a6058d65c62cb Mon Sep 17 00:00:00 2001 From: Kacper Date: Wed, 24 Dec 2025 02:31:56 +0100 Subject: [PATCH 18/18] refactor: Enhance fetchIssues logic with mounted state checks - Introduced a useRef hook to track component mount status, preventing state updates on unmounted components. - Updated fetchIssues function to conditionally set state only if the component is still mounted, improving reliability during asynchronous operations. - Ensured proper cleanup in useEffect to maintain accurate mounted state, enhancing overall component stability. --- .../hooks/use-github-issues.ts | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts index 27e33488..74b4b0b2 100644 --- a/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts +++ b/apps/ui/src/components/views/github-issues-view/hooks/use-github-issues.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { getElectronAPI, GitHubIssue } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; @@ -9,41 +9,59 @@ export function useGithubIssues() { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const isMountedRef = useRef(true); const fetchIssues = useCallback(async () => { if (!currentProject?.path) { - setError('No project selected'); - setLoading(false); + if (isMountedRef.current) { + setError('No project selected'); + setLoading(false); + } return; } try { - setError(null); + if (isMountedRef.current) { + setError(null); + } const api = getElectronAPI(); if (api.github) { const result = await api.github.listIssues(currentProject.path); - if (result.success) { - setOpenIssues(result.openIssues || []); - setClosedIssues(result.closedIssues || []); - } else { - setError(result.error || 'Failed to fetch issues'); + if (isMountedRef.current) { + if (result.success) { + setOpenIssues(result.openIssues || []); + setClosedIssues(result.closedIssues || []); + } else { + setError(result.error || 'Failed to fetch issues'); + } } } } catch (err) { - console.error('[GitHubIssuesView] Error fetching issues:', err); - setError(err instanceof Error ? err.message : 'Failed to fetch issues'); + if (isMountedRef.current) { + console.error('[GitHubIssuesView] Error fetching issues:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch issues'); + } } finally { - setLoading(false); - setRefreshing(false); + if (isMountedRef.current) { + setLoading(false); + setRefreshing(false); + } } }, [currentProject?.path]); useEffect(() => { + isMountedRef.current = true; fetchIssues(); + + return () => { + isMountedRef.current = false; + }; }, [fetchIssues]); const refresh = useCallback(() => { - setRefreshing(true); + if (isMountedRef.current) { + setRefreshing(true); + } fetchIssues(); }, [fetchIssues]);