+
+
Modal Title
+
Modal content goes here
+
+ setIsOpen(false)}>
+ Close
+
+
+
+
setIsOpen(false)} />
+
+ )}
+ >
+ )
+}
+```
+
+#### Dropdown Menu
+```tsx
+// DaisyUI Dropdown
+
+
+// Or Headless UI for complex dropdowns
+import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
+
+
+ Options
+
+
+ {({ focus }) => (
+
+ Account settings
+
+ )}
+
+
+ {({ focus }) => (
+
+ Support
+
+ )}
+
+
+
+```
+
+#### Form Controls
+
+```tsx
+// Checkbox
+
+
+
+
+// Radio
+
+
+
+// Toggle/Switch
+
+
+
+// Select
+
+ Pick your favorite Simpson
+ Homer
+ Marge
+ Bart
+ Lisa
+ Maggie
+
+
+// Range Slider
+
+```
+
+#### Tabs
+```tsx
+// DaisyUI Tabs
+
+
+// Or Headless UI for complex tab behavior
+import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
+
+
+
+ Tab 1
+ Tab 2
+ Tab 3
+
+
+ Content 1
+ Content 2
+ Content 3
+
+
+```
+
+#### Toast/Alert
+```tsx
+// DaisyUI Alert
+
+
+
New software update available.
+
+
+
+ Your purchase has been confirmed!
+
+
+
+ Warning: Invalid email address!
+
+
+
+ Error! Task failed successfully.
+
+```
+
+#### Tooltip
+```tsx
+// DaisyUI Tooltip
+
+ Hover me
+
+
+
+ Hover me
+
+```
+
+#### Progress Bar
+```tsx
+// DaisyUI Progress
+
+
+
+```
+
+#### Loading States
+```tsx
+// DaisyUI Loading
+
+
+
+
+
+
+
+// Different sizes
+
+
+
+
+```
+
+## Complex Components (Use Headless UI)
+
+### Popover
+```tsx
+import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
+
+
+ Open popover
+
+
+
Popover content
+
Your popover content goes here
+
+
+
+```
+
+### Combobox (Autocomplete)
+```tsx
+import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from '@headlessui/react'
+import { useState } from 'react'
+
+function AutoComplete() {
+ const [selectedPerson, setSelectedPerson] = useState(null)
+ const [query, setQuery] = useState('')
+
+ const people = [
+ { id: 1, name: 'Durward Reynolds' },
+ { id: 2, name: 'Kenton Towne' },
+ { id: 3, name: 'Therese Wunsch' },
+ ]
+
+ const filteredPeople = query === ''
+ ? people
+ : people.filter((person) =>
+ person.name.toLowerCase().includes(query.toLowerCase())
+ )
+
+ return (
+
+ person?.name}
+ onChange={(event) => setQuery(event.target.value)}
+ />
+
+ {filteredPeople.map((person) => (
+
+ {({ focus, selected }) => (
+
+ {person.name}
+
+ )}
+
+ ))}
+
+
+ )
+}
+```
+
+## Theme Integration
+
+DaisyUI works with your existing Tailwind setup and offers multiple themes:
+
+```tsx
+// Set theme on html element
+
+
+
+
+// ... many more themes available
+```
+
+## Installation Commands
+
+Add to your project:
+
+```bash
+# DaisyUI (already in your tailwind.config)
+npm install -D daisyui@latest
+
+# Headless UI for complex components
+npm install @headlessui/react
+
+# Optional: Heroicons for consistent icons
+npm install @heroicons/react
+```
+
+## Best Practices
+
+1. **Use DaisyUI for**: Simple components (buttons, inputs, cards, alerts)
+2. **Use Headless UI for**: Complex interactions (combobox, popover, complex menus)
+3. **Combine both**: Use DaisyUI classes within Headless UI components for styling
+4. **Always prefer**: These modern alternatives over deprecated Radix UI
+5. **Testing**: Both libraries offer excellent accessibility and React 19 compatibility
+
+## Migration Priority
+
+1. **High Priority**: Dialog, Dropdown, Popover, Tabs (most commonly used)
+2. **Medium Priority**: Form controls, Progress, Tooltip
+3. **Low Priority**: Advanced components like Accordion, Navigation Menu
+
+Remember: DaisyUI + Headless UI provides better performance, smaller bundle size, and active maintenance compared to Radix UI.
\ No newline at end of file
diff --git a/package.json b/package.json
index 7884d9b7..d1121504 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"@e2b/code-interpreter": "1.5.1",
"@eslint/plugin-kit": "^0.3.5",
"@geist-ui/core": "^2.3.8",
+ "@headlessui/react": "^2.2.7",
"@hookform/resolvers": "^5.2.1",
"@lottiefiles/dotlottie-react": "^0.14.4",
"@microsoft/eslint-formatter-sarif": "3.1.0",
@@ -133,6 +134,7 @@
"@vitejs/plugin-react-swc": "^4.0.0",
"autoprefixer": "^10.4.21",
"concurrently": "^9.2.0",
+ "daisyui": "^5.0.50",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
diff --git a/src/components/ChatInterface.tsx b/src/components/ChatInterface.tsx
index 8500708b..d08de9e9 100644
--- a/src/components/ChatInterface.tsx
+++ b/src/components/ChatInterface.tsx
@@ -198,8 +198,14 @@ const ChatInterface: React.FC = () => {
);
// Memoize normalized results to prevent useEffect dependencies from changing on every render
- const chats = React.useMemo(() => chatsData?.chats ?? [], [chatsData?.chats]);
- const messages = React.useMemo(() => messagesData?.messages ?? [], [messagesData?.messages]);
+ const chats = React.useMemo(() => {
+ const chatsArray = chatsData?.chats;
+ return Array.isArray(chatsArray) ? chatsArray : [];
+ }, [chatsData?.chats]);
+ const messages = React.useMemo(() => {
+ const messagesArray = messagesData?.messages;
+ return Array.isArray(messagesArray) ? messagesArray : [];
+ }, [messagesData?.messages]);
const createChat = useMutation(api.chats.createChat);
const updateChat = useMutation(api.chats.updateChat);
const createMessage = useMutation(api.messages.createMessage);
@@ -325,8 +331,18 @@ const ChatInterface: React.FC = () => {
error: error instanceof Error ? error.message : String(error),
title: 'New chat'
});
- Sentry.captureException(error);
- toast.error('Failed to create chat');
+
+ const errorMessage = error instanceof Error ? error.message : String(error);
+
+ // Handle specific error types with helpful messages
+ if (errorMessage.includes('Free plan limit reached')) {
+ toast.error('Free plan limit reached! You can create up to 5 chats. Upgrade to Pro for unlimited chats.');
+ } else if (errorMessage.includes('Rate limit exceeded')) {
+ toast.error('Please wait a moment before creating another chat.');
+ } else {
+ Sentry.captureException(error);
+ toast.error('Failed to create chat');
+ }
}
}
);
diff --git a/src/components/EnhancedChatInterface.tsx b/src/components/EnhancedChatInterface.tsx
index 6369131c..d0b4ed03 100644
--- a/src/components/EnhancedChatInterface.tsx
+++ b/src/components/EnhancedChatInterface.tsx
@@ -34,7 +34,9 @@ import {
ArrowUp,
Mic,
Paperclip,
- Settings
+ Settings,
+ Github,
+ GitBranch
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useQuery, useMutation } from 'convex/react';
@@ -52,6 +54,9 @@ import { toast } from 'sonner';
import * as Sentry from '@sentry/react';
import WebContainerFailsafe from './WebContainerFailsafe';
import { DECISION_PROMPT_NEXT } from '@/lib/decisionPrompt';
+import { GitHubIntegration } from '@/components/GitHubIntegration';
+import type { GitHubRepo } from '@/lib/github-service';
+import { githubService } from '@/lib/github-service';
const { logger } = Sentry;
@@ -152,17 +157,33 @@ const EnhancedChatInterface: React.FC = () => {
// Enhanced animations
const [messageAnimations, setMessageAnimations] = useState<{ [key: string]: boolean }>({});
+
+ // GitHub Integration state
+ const [selectedRepo, setSelectedRepo] = useState
(null);
+ const [githubContext, setGithubContext] = useState('');
+ const [isGithubMode, setIsGithubMode] = useState(false);
const textareaRef = useRef(null);
const messagesEndRef = useRef(null);
const inputContainerRef = useRef(null);
// Convex queries and mutations
- const chats = useQuery(api.chats.getUserChats);
- const messages = useQuery(
+ const chatsData = useQuery(api.chats.getUserChats);
+ const messagesData = useQuery(
api.messages.getChatMessages,
selectedChatId ? { chatId: selectedChatId as Id<'chats'> } : "skip"
);
+
+ // Ensure arrays are properly handled
+ const chats = React.useMemo(() => {
+ const chatsArray = chatsData?.chats;
+ return Array.isArray(chatsArray) ? chatsArray : [];
+ }, [chatsData?.chats]);
+
+ const messages = React.useMemo(() => {
+ const messagesArray = messagesData?.messages;
+ return Array.isArray(messagesArray) ? messagesArray : [];
+ }, [messagesData?.messages]);
const createChatMutation = useMutation(api.chats.createChat);
const addMessageMutation = useMutation(api.messages.createMessage);
@@ -209,6 +230,9 @@ const EnhancedChatInterface: React.FC = () => {
const sanitizedInput = sanitizeText(input);
if (!sanitizedInput.trim()) return;
+
+ // Enhance message with GitHub context if available
+ const enhancedInput = await enhanceMessageWithGitHub(sanitizedInput);
if (!sessionStarted) {
setSessionStarted(true);
@@ -228,9 +252,12 @@ const EnhancedChatInterface: React.FC = () => {
// Add user message
await addMessageMutation({
chatId: currentChatId as Id<'chats'>,
- content: sanitizedInput,
+ content: sanitizedInput, // Store original user input
role: 'user',
- metadata: {}
+ metadata: {
+ githubMode: isGithubMode,
+ repository: selectedRepo?.full_name
+ }
});
setInput('');
@@ -238,7 +265,7 @@ const EnhancedChatInterface: React.FC = () => {
// Generate AI response with enhanced error handling
try {
- const stream = await streamAIResponse(sanitizedInput);
+ const stream = await streamAIResponse(enhancedInput); // Use enhanced input for AI
let assistantResponse = '';
// Add assistant message placeholder
@@ -301,7 +328,17 @@ const EnhancedChatInterface: React.FC = () => {
} catch (error) {
logger.error('Chat submission failed:', error);
- toast.error('Failed to send message. Please try again.');
+
+ const errorMessage = error instanceof Error ? error.message : String(error);
+
+ // Handle specific error types with helpful messages
+ if (errorMessage.includes('Free plan limit reached')) {
+ toast.error('Free plan limit reached! You can create up to 5 chats. Upgrade to Pro for unlimited chats.');
+ } else if (errorMessage.includes('Rate limit exceeded')) {
+ toast.error('Please wait a moment before creating another chat.');
+ } else {
+ toast.error('Failed to send message. Please try again.');
+ }
} finally {
setIsTyping(false);
}
@@ -430,6 +467,78 @@ const EnhancedChatInterface: React.FC = () => {
});
};
+ // GitHub Integration Functions
+ const handleRepoSelected = (repo: GitHubRepo) => {
+ setSelectedRepo(repo);
+ setIsGithubMode(true);
+
+ const repoContext = `Repository: ${repo.full_name}\n` +
+ `Description: ${repo.description || 'No description'}\n` +
+ `Language: ${repo.language || 'Unknown'}\n` +
+ `Default Branch: ${repo.default_branch}\n` +
+ `Type: ${repo.private ? 'Private' : 'Public'} repository\n\n`;
+
+ setGithubContext(repoContext);
+
+ // Add context to the current input
+ setInput(prev => {
+ const newInput = `I'm working with this GitHub repository:\n\n${repoContext}` +
+ `Please help me analyze and suggest improvements for this codebase. ` +
+ `${prev ? '\n\n' + prev : ''}`;
+ return newInput;
+ });
+
+ toast.success(`Repository ${repo.full_name} loaded for AI analysis!`);
+ };
+
+ const handlePullRequestCreated = (prUrl: string, repo: GitHubRepo) => {
+ toast.success('Pull request created successfully!');
+
+ // Add success message to chat
+ if (selectedChatId) {
+ addMessageMutation({
+ chatId: selectedChatId as Id<'chats'>,
+ content: `✅ **Pull Request Created Successfully!**\n\n` +
+ `Repository: ${repo.full_name}\n` +
+ `Pull Request: [View PR](${prUrl})\n\n` +
+ `The changes have been applied and are ready for review. ` +
+ `You can now review the pull request on GitHub and merge it when ready.`,
+ role: 'assistant',
+ metadata: {
+ type: 'github_success',
+ prUrl,
+ repository: repo.full_name
+ }
+ }).catch(error => {
+ logger.error('Failed to add PR success message:', error);
+ });
+ }
+ };
+
+ const detectGithubUrls = (text: string): string[] => {
+ const githubUrlRegex = /https?:\/\/github\.com\/[\w\-.]+\/[\w\-.]+(?:\/[^\s]*)?/g;
+ return text.match(githubUrlRegex) || [];
+ };
+
+ const enhanceMessageWithGitHub = async (message: string): Promise => {
+ let enhancedMessage = message;
+
+ // If GitHub mode is active, add repository context
+ if (isGithubMode && selectedRepo && githubContext) {
+ enhancedMessage = githubContext + '\n' + message;
+ }
+
+ // Detect and suggest GitHub integration
+ const githubUrls = detectGithubUrls(message);
+ if (githubUrls.length > 0 && !isGithubMode) {
+ enhancedMessage += '\n\n[Assistant Note: I detected GitHub repository URLs in your message. ' +
+ 'Would you like to use the GitHub integration to analyze the repository, ' +
+ 'make changes, and create pull requests directly?]';
+ }
+
+ return enhancedMessage;
+ };
+
if (authLoading) {
return (
@@ -1048,6 +1157,64 @@ const EnhancedChatInterface: React.FC = () => {
+ {/* GitHub Mode Indicator */}
+
+ {isGithubMode && selectedRepo && (
+
+
+
+
+
+
+ GitHub Mode Active
+
+
+
+
{selectedRepo.full_name}
+
+ {selectedRepo.language || 'Unknown'}
+
+
+
+
+ window.open(selectedRepo.html_url, '_blank')}
+ className="h-6 px-2 text-xs"
+ >
+
+ View
+
+ {
+ setIsGithubMode(false);
+ setSelectedRepo(null);
+ setGithubContext('');
+ toast.info('GitHub mode disabled');
+ }}
+ className="h-6 px-2 text-xs"
+ >
+ ×
+
+
+
+
+
+ )}
+
+
{/* Enhanced Input Area */}
@@ -1092,6 +1259,11 @@ const EnhancedChatInterface: React.FC = () => {
Clone
+
diff --git a/src/components/GitHubIntegration.tsx b/src/components/GitHubIntegration.tsx
new file mode 100644
index 00000000..fcb582ab
--- /dev/null
+++ b/src/components/GitHubIntegration.tsx
@@ -0,0 +1,658 @@
+import React, { useState, useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import { Badge } from '@/components/ui/badge';
+import { Separator } from '@/components/ui/separator';
+import { Label } from '@/components/ui/label';
+import {
+ GitBranch,
+ GitFork,
+ GitPullRequest,
+ Github,
+ ExternalLink,
+ Loader2,
+ Check,
+ AlertCircle,
+ Settings,
+ Key,
+ FileText,
+ Folder,
+ Plus
+} from 'lucide-react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { toast } from 'sonner';
+import { githubService, initializeGitHub, type GitHubRepo, type FileChange, type CreatePullRequestOptions } from '@/lib/github-service';
+import { setGitHubToken, clearGitHubToken } from '@/lib/github-token-storage';
+import * as Sentry from '@sentry/react';
+
+const { logger } = Sentry;
+
+export interface GitHubIntegrationProps {
+ onRepoSelected?: (repo: GitHubRepo) => void;
+ onPullRequestCreated?: (prUrl: string, repo: GitHubRepo) => void;
+ className?: string;
+}
+
+interface GitHubOperationStatus {
+ stage: 'idle' | 'parsing' | 'forking' | 'creating-branch' | 'applying-changes' | 'creating-pr' | 'completed' | 'error';
+ message: string;
+ progress: number;
+}
+
+export function GitHubIntegration({
+ onRepoSelected,
+ onPullRequestCreated,
+ className = ''
+}: GitHubIntegrationProps) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [githubUrl, setGithubUrl] = useState('');
+ const [isTokenSetup, setIsTokenSetup] = useState(false);
+ const [githubToken, setGithubToken] = useState('');
+ const [currentRepo, setCurrentRepo] = useState
(null);
+ const [operationStatus, setOperationStatus] = useState({
+ stage: 'idle',
+ message: 'Ready to start',
+ progress: 0
+ });
+
+ // Pull Request creation fields
+ const [prTitle, setPrTitle] = useState('');
+ const [prDescription, setPrDescription] = useState('');
+ const [changes, setChanges] = useState([]);
+ const [showPRForm, setShowPRForm] = useState(false);
+
+ // Token setup
+ const [showTokenSetup, setShowTokenSetup] = useState(false);
+
+ useEffect(() => {
+ checkGitHubSetup();
+ }, []);
+
+ const checkGitHubSetup = async () => {
+ const isSetup = await initializeGitHub();
+ setIsTokenSetup(isSetup);
+ };
+
+ const saveGitHubToken = async () => {
+ if (!githubToken.trim()) {
+ toast.error('Please enter a valid GitHub token');
+ return;
+ }
+
+ if (!githubService.validateGitHubToken(githubToken)) {
+ toast.error('Invalid GitHub token format. Please check your token.');
+ return;
+ }
+
+ try {
+ await setGitHubToken(githubToken.trim());
+ githubService.setToken(githubToken.trim());
+
+ // Clear token from component state immediately after use
+ setGithubToken('');
+
+ setIsTokenSetup(true);
+ setShowTokenSetup(false);
+ toast.success('GitHub token saved securely!');
+ } catch (error) {
+ logger.error('Failed to save GitHub token:', error);
+ toast.error('Failed to save GitHub token. Please try again.');
+ }
+ };
+
+ const parseAndLoadRepo = async () => {
+ if (!githubUrl.trim()) {
+ toast.error('Please enter a GitHub repository URL');
+ return;
+ }
+
+ if (!isTokenSetup) {
+ toast.error('Please configure your GitHub token first');
+ setShowTokenSetup(true);
+ return;
+ }
+
+ try {
+ setOperationStatus({
+ stage: 'parsing',
+ message: 'Parsing GitHub URL...',
+ progress: 10
+ });
+
+ const parsed = await githubService.parseRepoUrl(githubUrl);
+ if (!parsed) {
+ throw new Error('Invalid GitHub URL format');
+ }
+
+ setOperationStatus({
+ stage: 'parsing',
+ message: `Loading repository ${parsed.owner}/${parsed.repo}...`,
+ progress: 30
+ });
+
+ const repo = await githubService.getRepo(parsed.owner, parsed.repo);
+ setCurrentRepo(repo);
+
+ setOperationStatus({
+ stage: 'completed',
+ message: `Repository loaded successfully!`,
+ progress: 100
+ });
+
+ if (onRepoSelected) {
+ onRepoSelected(repo);
+ }
+
+ toast.success(`Repository ${repo.full_name} loaded successfully!`);
+ } catch (error) {
+ logger.error('Error loading repository:', error);
+ setOperationStatus({
+ stage: 'error',
+ message: error instanceof Error ? error.message : 'Failed to load repository',
+ progress: 0
+ });
+ toast.error(error instanceof Error ? error.message : 'Failed to load repository');
+ }
+ };
+
+ const createPullRequest = async () => {
+ if (!currentRepo || changes.length === 0) {
+ toast.error('No changes to commit');
+ return;
+ }
+
+ if (!prTitle.trim()) {
+ toast.error('Please enter a pull request title');
+ return;
+ }
+
+ try {
+ const originalOwner = currentRepo.owner.login;
+ const originalRepo = currentRepo.name;
+
+ setOperationStatus({
+ stage: 'forking',
+ message: 'Forking repository...',
+ progress: 20
+ });
+
+ // Fork the repository
+ const forkedRepo = await githubService.forkRepo(originalOwner, originalRepo);
+ const userLogin = forkedRepo.owner.login;
+
+ setOperationStatus({
+ stage: 'creating-branch',
+ message: 'Creating feature branch...',
+ progress: 40
+ });
+
+ // Create a new branch
+ const branchName = githubService.generateBranchName(prTitle);
+ await githubService.createBranch(userLogin, originalRepo, branchName, currentRepo.default_branch);
+
+ setOperationStatus({
+ stage: 'applying-changes',
+ message: 'Applying changes...',
+ progress: 60
+ });
+
+ // Apply changes
+ await githubService.updateFiles(
+ userLogin,
+ originalRepo,
+ branchName,
+ changes,
+ prTitle
+ );
+
+ setOperationStatus({
+ stage: 'creating-pr',
+ message: 'Creating pull request...',
+ progress: 80
+ });
+
+ // Create pull request using options object pattern
+ const prOptions: CreatePullRequestOptions = {
+ owner: originalOwner,
+ repo: originalRepo,
+ title: prTitle,
+ body: prDescription,
+ headBranch: branchName,
+ baseBranch: currentRepo.default_branch,
+ originalOwner: originalOwner
+ };
+
+ const pr = await githubService.createPullRequest(prOptions);
+
+ setOperationStatus({
+ stage: 'completed',
+ message: 'Pull request created successfully!',
+ progress: 100
+ });
+
+ if (onPullRequestCreated) {
+ onPullRequestCreated(pr.html_url, currentRepo);
+ }
+
+ toast.success(`Pull request created: ${pr.html_url}`);
+
+ // Reset form
+ setShowPRForm(false);
+ setPrTitle('');
+ setPrDescription('');
+ setChanges([]);
+
+ } catch (error) {
+ logger.error('Error creating pull request:', error);
+ setOperationStatus({
+ stage: 'error',
+ message: error instanceof Error ? error.message : 'Failed to create pull request',
+ progress: 0
+ });
+ toast.error(error instanceof Error ? error.message : 'Failed to create pull request');
+ }
+ };
+
+ const addFileChange = () => {
+ setChanges([...changes, { path: '', content: '', action: 'create' }]);
+ };
+
+ const updateFileChange = (index: number, field: keyof FileChange, value: string) => {
+ const updatedChanges = [...changes];
+ updatedChanges[index] = { ...updatedChanges[index], [field]: value };
+ setChanges(updatedChanges);
+ };
+
+ const removeFileChange = (index: number) => {
+ setChanges(changes.filter((_, i) => i !== index));
+ };
+
+ const getStatusIcon = () => {
+ switch (operationStatus.stage) {
+ case 'parsing':
+ case 'forking':
+ case 'creating-branch':
+ case 'applying-changes':
+ case 'creating-pr':
+ return ;
+ case 'completed':
+ return ;
+ case 'error':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+
+
+
+ GitHub Integration
+
+
+
+
+
+
+ GitHub Integration
+
+
+
+
+ {/* GitHub Token Setup */}
+
+
+
+
+ GitHub Token Setup
+ {isTokenSetup && Configured }
+
+
+
+ {!isTokenSetup ? (
+
+
+ You need a GitHub Personal Access Token to fork repositories and create pull requests.
+
+
+ setShowTokenSetup(!showTokenSetup)}
+ >
+
+ Configure Token
+
+ window.open('https://github.com/settings/tokens', '_blank')}
+ >
+
+ Generate Token
+
+
+
+ {showTokenSetup && (
+
+ GitHub Personal Access Token
+ setGithubToken(e.target.value)}
+ className="font-mono"
+ />
+
+ Required scopes: repo, workflow, write:packages
+
+
+
+ Save Token
+
+ {
+ setGithubToken('');
+ setShowTokenSetup(false);
+ }}
+ >
+ Cancel
+
+
+
+ )}
+
+ ) : (
+
+
+
GitHub token configured successfully
+
+ setShowTokenSetup(true)}
+ >
+ Update
+
+ {
+ try {
+ await clearGitHubToken();
+ setIsTokenSetup(false);
+ toast.success('GitHub token removed');
+ } catch (error) {
+ logger.error('Failed to clear token:', error);
+ toast.error('Failed to remove token');
+ }
+ }}
+ className="text-red-400 hover:text-red-300"
+ >
+ Remove
+
+
+
+ )}
+
+
+
+ {/* Repository Input */}
+
+
+
+
+ Repository Selection
+
+
+
+
+
GitHub Repository URL
+
+ setGithubUrl(e.target.value)}
+ disabled={operationStatus.stage !== 'idle' && operationStatus.stage !== 'completed' && operationStatus.stage !== 'error'}
+ />
+
+ {operationStatus.stage === 'parsing' ? (
+
+ ) : (
+ 'Load Repo'
+ )}
+
+
+
+
+ {/* Operation Status */}
+
+ {operationStatus.stage !== 'idle' && (
+
+
+ {getStatusIcon()}
+ {operationStatus.message}
+
+
+
+ )}
+
+
+ {/* Repository Info */}
+
+ {currentRepo && (
+
+
+
+
+
{currentRepo.full_name}
+
{currentRepo.description}
+
+
+ {currentRepo.language && (
+ {currentRepo.language}
+ )}
+
+ {currentRepo.private ? 'Private' : 'Public'}
+
+
+
+
+
+ window.open(currentRepo.html_url, '_blank')}
+ >
+
+ View on GitHub
+
+ setShowPRForm(true)}
+ >
+
+ Create Pull Request
+
+
+
+ )}
+
+
+
+
+ {/* Pull Request Form */}
+
+ {showPRForm && currentRepo && (
+
+
+
+
+
+ Create Pull Request
+
+
+
+
+
+ Pull Request Title *
+ setPrTitle(e.target.value)}
+ />
+
+
+
+ Description
+
+
+
+
+
+ {/* File Changes */}
+
+
+
File Changes ({changes.length})
+
+
+ Add File
+
+
+
+ {changes.map((change, index) => (
+
+
+ updateFileChange(index, 'path', e.target.value)}
+ className="flex-1"
+ />
+ updateFileChange(index, 'action', e.target.value as FileChange['action'])}
+ className="px-3 py-2 rounded-md border border-input bg-background"
+ >
+ Create
+ Update
+ Delete
+
+ removeFileChange(index)}
+ className="text-red-500"
+ >
+ ×
+
+
+ {change.action !== 'delete' && (
+
+ ))}
+
+ {changes.length === 0 && (
+
+
+
No file changes added yet.
+
Add files to create a pull request.
+
+ )}
+
+
+
+ setShowPRForm(false)}
+ >
+ Cancel
+
+
+ {(operationStatus.stage === 'forking' || operationStatus.stage === 'creating-branch' || operationStatus.stage === 'applying-changes' || operationStatus.stage === 'creating-pr') ? (
+
+ ) : (
+
+ )}
+ Create Pull Request
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/lib/github-service.ts b/src/lib/github-service.ts
new file mode 100644
index 00000000..b211ff88
--- /dev/null
+++ b/src/lib/github-service.ts
@@ -0,0 +1,373 @@
+import { toast } from 'sonner';
+import * as Sentry from '@sentry/react';
+import { getGitHubToken as getSecureGitHubToken, validateGitHubToken as validateSecureGitHubToken, migrateFromLocalStorage } from './github-token-storage';
+
+const { logger } = Sentry;
+
+export interface GitHubRepo {
+ id: number;
+ name: string;
+ full_name: string;
+ description: string | null;
+ clone_url: string;
+ html_url: string;
+ default_branch: string;
+ owner: {
+ login: string;
+ avatar_url: string;
+ };
+ private: boolean;
+ fork: boolean;
+ language: string | null;
+ topics: string[];
+ updated_at: string;
+}
+
+export interface GitHubUser {
+ login: string;
+ avatar_url: string;
+ name: string;
+ email: string;
+}
+
+export interface GitHubPullRequest {
+ id: number;
+ number: number;
+ title: string;
+ body: string | null;
+ html_url: string;
+ state: 'open' | 'closed' | 'merged';
+ head: {
+ ref: string;
+ sha: string;
+ };
+ base: {
+ ref: string;
+ };
+}
+
+export interface FileChange {
+ path: string;
+ content: string;
+ action: 'create' | 'update' | 'delete';
+}
+
+export interface CreatePullRequestOptions {
+ owner: string;
+ repo: string;
+ title: string;
+ body: string;
+ headBranch: string;
+ baseBranch?: string;
+ originalOwner?: string;
+}
+
+export interface UpdateFilesBody {
+ message: string;
+ content: string;
+ branch: string;
+ sha?: string;
+}
+
+class GitHubService {
+ private baseUrl = 'https://api.github.com';
+ private token: string | null = null;
+
+ setToken(token: string) {
+ this.token = token;
+ }
+
+ private async request(
+ endpoint: string,
+ options: RequestInit = {}
+ ): Promise {
+ if (!this.token) {
+ throw new Error('GitHub token not set. Please configure your GitHub access token.');
+ }
+
+ const url = `${this.baseUrl}${endpoint}`;
+ const response = await fetch(url, {
+ ...options,
+ headers: {
+ Authorization: `Bearer ${this.token}`,
+ Accept: 'application/vnd.github.v3+json',
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ });
+
+ if (!response.ok) {
+ const error = await response.json().catch(() => ({ message: 'Unknown error' }));
+ throw new Error(error.message || `GitHub API error: ${response.status}`);
+ }
+
+ return response.json();
+ }
+
+ async getCurrentUser(): Promise {
+ return this.request('/user');
+ }
+
+ async getRepo(owner: string, repo: string): Promise {
+ return this.request(`/repos/${owner}/${repo}`);
+ }
+
+ async parseRepoUrl(url: string): Promise<{ owner: string; repo: string } | null> {
+ try {
+ // Handle various GitHub URL formats
+ const patterns = [
+ /github\.com\/([^/]+)\/([^/]+)(?:\/.*)?$/,
+ /github\.com\/([^/]+)\/([^/]+)\.git$/,
+ /git@github\.com:([^/]+)\/([^/]+)\.git$/,
+ ];
+
+ let cleanUrl = url.trim();
+ if (cleanUrl.startsWith('http://') || cleanUrl.startsWith('https://')) {
+ cleanUrl = new URL(cleanUrl).pathname;
+ }
+
+ for (const pattern of patterns) {
+ const match = cleanUrl.match(pattern) || url.match(pattern);
+ if (match) {
+ const [, owner, repo] = match;
+ return {
+ owner: owner.trim(),
+ repo: repo.replace(/\.git$/, '').trim()
+ };
+ }
+ }
+
+ return null;
+ } catch (error) {
+ logger.error('Error parsing GitHub URL:', error);
+ return null;
+ }
+ }
+
+ async forkRepo(owner: string, repo: string): Promise {
+ try {
+ const forkedRepo = await this.request(`/repos/${owner}/${repo}/forks`, {
+ method: 'POST',
+ });
+
+ // Wait a moment for fork to be ready
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ return forkedRepo;
+ } catch (error) {
+ logger.error('Error forking repository:', error);
+ throw new Error(`Failed to fork repository: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ async createBranch(
+ owner: string,
+ repo: string,
+ branchName: string,
+ fromBranch: string = 'main'
+ ): Promise {
+ try {
+ // Get the SHA of the base branch
+ const baseRef = await this.request<{ object: { sha: string } }>(
+ `/repos/${owner}/${repo}/git/ref/heads/${fromBranch}`
+ );
+
+ // Create new branch
+ await this.request(`/repos/${owner}/${repo}/git/refs`, {
+ method: 'POST',
+ body: JSON.stringify({
+ ref: `refs/heads/${branchName}`,
+ sha: baseRef.object.sha,
+ }),
+ });
+ } catch (error) {
+ logger.error('Error creating branch:', error);
+ throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ async updateFiles(
+ owner: string,
+ repo: string,
+ branch: string,
+ files: FileChange[],
+ commitMessage: string
+ ): Promise {
+ try {
+ for (const file of files) {
+ if (file.action === 'delete') {
+ // Get file SHA first for deletion
+ const fileData = await this.request<{ sha: string }>(
+ `/repos/${owner}/${repo}/contents/${file.path}?ref=${branch}`
+ );
+
+ await this.request(`/repos/${owner}/${repo}/contents/${file.path}`, {
+ method: 'DELETE',
+ body: JSON.stringify({
+ message: `Delete ${file.path} - ${commitMessage}`,
+ sha: fileData.sha,
+ branch: branch,
+ }),
+ });
+ } else {
+ // Create or update file
+ const body: UpdateFilesBody = {
+ message: `${file.action === 'create' ? 'Create' : 'Update'} ${file.path} - ${commitMessage}`,
+ content: btoa(unescape(encodeURIComponent(file.content))), // Base64 encode
+ branch: branch,
+ };
+
+ if (file.action === 'update') {
+ // Get current file SHA for update
+ try {
+ const fileData = await this.request<{ sha: string }>(
+ `/repos/${owner}/${repo}/contents/${file.path}?ref=${branch}`
+ );
+ body.sha = fileData.sha;
+ } catch (error) {
+ // File might not exist, create it instead
+ logger.info(`File ${file.path} not found, creating new file`, { filePath: file.path, action: 'create' });
+ }
+ }
+
+ await this.request(`/repos/${owner}/${repo}/contents/${file.path}`, {
+ method: 'PUT',
+ body: JSON.stringify(body),
+ });
+ }
+ }
+ } catch (error) {
+ logger.error('Error updating files:', error);
+ throw new Error(`Failed to update files: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ async createPullRequest(options: CreatePullRequestOptions): Promise {
+ try {
+ const { owner, repo, title, body, headBranch, baseBranch = 'main', originalOwner } = options;
+ const head = originalOwner ? `${owner}:${headBranch}` : headBranch;
+ const base = baseBranch;
+
+ const pr = await this.request(
+ `/repos/${originalOwner || owner}/${repo}/pulls`,
+ {
+ method: 'POST',
+ body: JSON.stringify({
+ title,
+ body,
+ head,
+ base,
+ }),
+ }
+ );
+
+ return pr;
+ } catch (error) {
+ logger.error('Error creating pull request:', error);
+ throw new Error(`Failed to create pull request: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ async getFileContent(
+ owner: string,
+ repo: string,
+ path: string,
+ branch: string = 'main'
+ ): Promise {
+ try {
+ const response = await this.request<{ content: string; encoding: string }>(
+ `/repos/${owner}/${repo}/contents/${path}?ref=${branch}`
+ );
+
+ if (response.encoding === 'base64') {
+ return atob(response.content.replace(/\s/g, ''));
+ }
+ return response.content;
+ } catch (error) {
+ logger.error('Error getting file content:', error);
+ throw new Error(`Failed to get file content: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ async getRepoStructure(
+ owner: string,
+ repo: string,
+ branch: string = 'main',
+ path: string = ''
+ ): Promise> {
+ try {
+ const response = await this.request>(`/repos/${owner}/${repo}/contents/${path}?ref=${branch}`);
+
+ return response;
+ } catch (error) {
+ logger.error('Error getting repository structure:', error);
+ throw new Error(`Failed to get repository structure: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ generateBranchName(description: string): string {
+ const timestamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
+ const sanitized = description
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .slice(0, 30);
+
+ return `zapdev-${sanitized}-${timestamp}`;
+ }
+
+ validateGitHubToken(token: string): boolean {
+ return validateSecureGitHubToken(token);
+ }
+}
+
+export const githubService = new GitHubService();
+
+// Helper function to get GitHub token from secure storage or environment
+export async function getGitHubToken(): Promise {
+ try {
+ // First attempt migration from legacy localStorage
+ await migrateFromLocalStorage();
+
+ // Try to get from secure storage first (user-provided token)
+ const secureToken = await getSecureGitHubToken();
+ if (secureToken && githubService.validateGitHubToken(secureToken)) {
+ return secureToken;
+ }
+
+ // Try environment variables as fallback
+ const envToken = process.env.VITE_GITHUB_TOKEN || process.env.GITHUB_TOKEN;
+ if (envToken && githubService.validateGitHubToken(envToken)) {
+ return envToken;
+ }
+
+ return null;
+ } catch (error) {
+ logger.error('Error retrieving GitHub token:', error);
+ return null;
+ }
+}
+
+export async function initializeGitHub(): Promise {
+ try {
+ const token = await getGitHubToken();
+ if (!token) {
+ return false;
+ }
+
+ githubService.setToken(token);
+
+ // Test the token by getting current user
+ await githubService.getCurrentUser();
+ return true;
+ } catch (error) {
+ logger.error('GitHub initialization failed:', error);
+ return false;
+ }
+}
+
+export { GitHubService };
\ No newline at end of file
diff --git a/src/lib/github-token-storage.ts b/src/lib/github-token-storage.ts
new file mode 100644
index 00000000..e2e8b027
--- /dev/null
+++ b/src/lib/github-token-storage.ts
@@ -0,0 +1,124 @@
+/**
+ * Secure GitHub token storage utility
+ * Uses the existing secure storage infrastructure for GitHub tokens specifically
+ */
+
+import { setSecureApiKey, getSecureApiKey, clearSecureApiKey, hasUserApiKey } from './secure-storage';
+import * as Sentry from '@sentry/react';
+
+const { logger } = Sentry;
+
+/**
+ * Validates GitHub token format with enhanced security checks
+ */
+export function validateGitHubToken(token: string): boolean {
+ if (!token || typeof token !== 'string') {
+ return false;
+ }
+
+ // Remove whitespace
+ token = token.trim();
+
+ // Check for proper GitHub token formats
+ const isClassicToken = token.startsWith('ghp_') && token.length === 40;
+ const isFineGrainedToken = token.startsWith('github_pat_') && token.length >= 82;
+
+ // Additional security checks
+ if (token.includes('