mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
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.
This commit is contained in:
@@ -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<GitHubIssue[]>([]);
|
||||
@@ -13,6 +46,9 @@ export function GitHubIssuesView() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedIssue, setSelectedIssue] = useState<GitHubIssue | null>(null);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<IssueValidationResult | null>(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() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleValidateIssue(selectedIssue)}
|
||||
disabled={validating}
|
||||
>
|
||||
{validating ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Validate with AI
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -260,6 +406,16 @@ export function GitHubIssuesView() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation Dialog */}
|
||||
<ValidationDialog
|
||||
open={showValidationDialog}
|
||||
onOpenChange={setShowValidationDialog}
|
||||
issue={selectedIssue}
|
||||
validationResult={validationResult}
|
||||
isValidating={validating}
|
||||
onConvertToTask={handleConvertToTask}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<IssueValidationConfidence, { label: string; color: string }> = {
|
||||
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<IssueComplexity, { label: string; color: string }> = {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">Issue Validation Result</DialogTitle>
|
||||
<DialogDescription>
|
||||
#{issue.number}: {issue.title}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isValidating ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Analyzing codebase to validate issue...</p>
|
||||
</div>
|
||||
) : validationResult ? (
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Verdict Badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{(() => {
|
||||
const config = verdictConfig[validationResult.verdict];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<>
|
||||
<div className={cn('p-2 rounded-lg', config.bgColor)}>
|
||||
<Icon className={cn('h-6 w-6', config.color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn('text-lg font-semibold', config.color)}>{config.label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm',
|
||||
confidenceConfig[validationResult.confidence].color
|
||||
)}
|
||||
>
|
||||
{confidenceConfig[validationResult.confidence].label}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{validationResult.estimatedComplexity && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-muted-foreground">Estimated Complexity</p>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
complexityConfig[validationResult.estimatedComplexity].color
|
||||
)}
|
||||
>
|
||||
{complexityConfig[validationResult.estimatedComplexity].label}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bug Confirmed Badge */}
|
||||
{validationResult.bugConfirmed && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-red-500">Bug Confirmed in Codebase</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reasoning */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
||||
Analysis
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{validationResult.reasoning}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Related Files */}
|
||||
{validationResult.relatedFiles && validationResult.relatedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<FileCode className="h-4 w-4 text-muted-foreground" />
|
||||
Related Files
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{validationResult.relatedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-sm font-mono bg-muted/50 px-2 py-1 rounded text-muted-foreground"
|
||||
>
|
||||
{file}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Fix */}
|
||||
{validationResult.suggestedFix && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Suggested Approach</h4>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap bg-muted/30 p-3 rounded-lg border border-border">
|
||||
{validationResult.suggestedFix}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missing Info (for needs_clarification) */}
|
||||
{validationResult.missingInfo && validationResult.missingInfo.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||
Missing Information
|
||||
</h4>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
{validationResult.missingInfo.map((info, index) => (
|
||||
<li key={index} className="text-sm text-muted-foreground">
|
||||
{info}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground">No validation result available.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{validationResult?.verdict === 'valid' && onConvertToTask && (
|
||||
<Button onClick={handleConvertToTask}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Convert to Task
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user