import React, { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { toast } from 'react-toastify'; import { logStore } from '~/lib/stores/logs'; import { classNames } from '~/utils/classNames'; import Cookies from 'js-cookie'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible'; import { Button } from '~/components/ui/Button'; interface GitHubUserResponse { login: string; avatar_url: string; html_url: string; name: string; bio: string; public_repos: number; followers: number; following: number; created_at: string; public_gists: number; } interface GitHubRepoInfo { name: string; full_name: string; html_url: string; description: string; stargazers_count: number; forks_count: number; default_branch: string; updated_at: string; languages_url: string; } interface GitHubOrganization { login: string; avatar_url: string; html_url: string; } interface GitHubEvent { id: string; type: string; repo: { name: string; }; created_at: string; } interface GitHubLanguageStats { [language: string]: number; } interface GitHubStats { repos: GitHubRepoInfo[]; recentActivity: GitHubEvent[]; languages: GitHubLanguageStats; totalGists: number; publicRepos: number; privateRepos: number; stars: number; forks: number; followers: number; publicGists: number; privateGists: number; lastUpdated: string; // Keep these for backward compatibility totalStars?: number; totalForks?: number; organizations?: GitHubOrganization[]; } interface GitHubConnection { user: GitHubUserResponse | null; token: string; tokenType: 'classic' | 'fine-grained'; stats?: GitHubStats; rateLimit?: { limit: number; remaining: number; reset: number; }; } // Add the GitHub logo SVG component const GithubLogo = () => ( ); export default function GitHubConnection() { const [connection, setConnection] = useState({ user: null, token: '', tokenType: 'classic', }); const [isLoading, setIsLoading] = useState(true); const [isConnecting, setIsConnecting] = useState(false); const [isFetchingStats, setIsFetchingStats] = useState(false); const [isStatsExpanded, setIsStatsExpanded] = useState(false); const tokenTypeRef = React.useRef<'classic' | 'fine-grained'>('classic'); const fetchGithubUser = async (token: string) => { try { console.log('Fetching GitHub user with token:', token.substring(0, 5) + '...'); // Use server-side API endpoint instead of direct GitHub API call const response = await fetch(`/api/system/git-info?action=getUser`, { method: 'GET', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, // Include token in headers for validation }, }); if (!response.ok) { console.error('Error fetching GitHub user. Status:', response.status); throw new Error(`Error: ${response.status}`); } // Get rate limit information from headers const rateLimit = { limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'), remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'), reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'), }; const data = await response.json(); console.log('GitHub user API response:', data); const { user } = data as { user: GitHubUserResponse }; // Validate that we received a user object if (!user || !user.login) { console.error('Invalid user data received:', user); throw new Error('Invalid user data received'); } // Use the response data setConnection((prev) => ({ ...prev, user, token, tokenType: tokenTypeRef.current, rateLimit, })); // Set cookies for client-side access Cookies.set('githubUsername', user.login); Cookies.set('githubToken', token); Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' })); // Store connection details in localStorage localStorage.setItem( 'github_connection', JSON.stringify({ user, token, tokenType: tokenTypeRef.current, }), ); logStore.logInfo('Connected to GitHub', { type: 'system', message: `Connected to GitHub as ${user.login}`, }); // Fetch additional GitHub stats fetchGitHubStats(token); } catch (error) { console.error('Failed to fetch GitHub user:', error); logStore.logError(`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, { type: 'system', message: 'GitHub authentication failed', }); toast.error(`Authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; // Rethrow to allow handling in the calling function } }; const fetchGitHubStats = async (token: string) => { setIsFetchingStats(true); try { // Get the current user first to ensure we have the latest value const userResponse = await fetch('https://api.github.com/user', { headers: { Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`, }, }); if (!userResponse.ok) { if (userResponse.status === 401) { toast.error('Your GitHub token has expired. Please reconnect your account.'); handleDisconnect(); return; } throw new Error(`Failed to fetch user data: ${userResponse.statusText}`); } const userData = (await userResponse.json()) as any; // Fetch repositories with pagination let allRepos: any[] = []; let page = 1; let hasMore = true; while (hasMore) { const reposResponse = await fetch(`https://api.github.com/user/repos?per_page=100&page=${page}`, { headers: { Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`, }, }); if (!reposResponse.ok) { throw new Error(`Failed to fetch repositories: ${reposResponse.statusText}`); } const repos = (await reposResponse.json()) as any[]; allRepos = [...allRepos, ...repos]; // Check if there are more pages const linkHeader = reposResponse.headers.get('Link'); hasMore = linkHeader?.includes('rel="next"') ?? false; page++; } // Calculate stats const repoStats = calculateRepoStats(allRepos); // Fetch recent activity const eventsResponse = await fetch(`https://api.github.com/users/${userData.login}/events?per_page=10`, { headers: { Authorization: `${connection.tokenType === 'classic' ? 'token' : 'Bearer'} ${token}`, }, }); if (!eventsResponse.ok) { throw new Error(`Failed to fetch events: ${eventsResponse.statusText}`); } const events = (await eventsResponse.json()) as any[]; const recentActivity = events.slice(0, 5).map((event: any) => ({ id: event.id, type: event.type, repo: event.repo.name, created_at: event.created_at, })); // Calculate total stars and forks const totalStars = allRepos.reduce((sum: number, repo: any) => sum + repo.stargazers_count, 0); const totalForks = allRepos.reduce((sum: number, repo: any) => sum + repo.forks_count, 0); const privateRepos = allRepos.filter((repo: any) => repo.private).length; // Update the stats in the store const stats: GitHubStats = { repos: repoStats.repos, recentActivity, languages: repoStats.languages || {}, totalGists: repoStats.totalGists || 0, publicRepos: userData.public_repos || 0, privateRepos: privateRepos || 0, stars: totalStars || 0, forks: totalForks || 0, followers: userData.followers || 0, publicGists: userData.public_gists || 0, privateGists: userData.private_gists || 0, lastUpdated: new Date().toISOString(), // For backward compatibility totalStars: totalStars || 0, totalForks: totalForks || 0, organizations: [], }; // Get the current user first to ensure we have the latest value const currentConnection = JSON.parse(localStorage.getItem('github_connection') || '{}'); const currentUser = currentConnection.user || connection.user; // Update connection with stats const updatedConnection: GitHubConnection = { user: currentUser, token, tokenType: connection.tokenType, stats, rateLimit: connection.rateLimit, }; // Update localStorage localStorage.setItem('github_connection', JSON.stringify(updatedConnection)); // Update state setConnection(updatedConnection); toast.success('GitHub stats refreshed'); } catch (error) { console.error('Error fetching GitHub stats:', error); toast.error(`Failed to fetch GitHub stats: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsFetchingStats(false); } }; const calculateRepoStats = (repos: any[]) => { const repoStats = { repos: repos.map((repo: any) => ({ name: repo.name, full_name: repo.full_name, html_url: repo.html_url, description: repo.description, stargazers_count: repo.stargazers_count, forks_count: repo.forks_count, default_branch: repo.default_branch, updated_at: repo.updated_at, languages_url: repo.languages_url, })), languages: {} as Record, totalGists: 0, }; repos.forEach((repo: any) => { fetch(repo.languages_url) .then((response) => response.json()) .then((languages: any) => { const typedLanguages = languages as Record; Object.keys(typedLanguages).forEach((language) => { if (!repoStats.languages[language]) { repoStats.languages[language] = 0; } repoStats.languages[language] += 1; }); }); }); return repoStats; }; useEffect(() => { const loadSavedConnection = async () => { setIsLoading(true); const savedConnection = localStorage.getItem('github_connection'); if (savedConnection) { try { const parsed = JSON.parse(savedConnection); if (!parsed.tokenType) { parsed.tokenType = 'classic'; } // Update the ref with the parsed token type tokenTypeRef.current = parsed.tokenType; // Set the connection setConnection(parsed); // If we have a token but no stats or incomplete stats, fetch them if ( parsed.user && parsed.token && (!parsed.stats || !parsed.stats.repos || parsed.stats.repos.length === 0) ) { console.log('Fetching missing GitHub stats for saved connection'); await fetchGitHubStats(parsed.token); } } catch (error) { console.error('Error parsing saved GitHub connection:', error); localStorage.removeItem('github_connection'); } } else { // Check for environment variable token const envToken = import.meta.env.VITE_GITHUB_ACCESS_TOKEN; if (envToken) { // Check if token type is specified in environment variables const envTokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE; console.log('Environment token type:', envTokenType); const tokenType = envTokenType === 'classic' || envTokenType === 'fine-grained' ? (envTokenType as 'classic' | 'fine-grained') : 'classic'; console.log('Using token type:', tokenType); // Update both the state and the ref tokenTypeRef.current = tokenType; setConnection((prev) => ({ ...prev, tokenType, })); try { // Fetch user data with the environment token await fetchGithubUser(envToken); } catch (error) { console.error('Failed to connect with environment token:', error); } } } setIsLoading(false); }; loadSavedConnection(); }, []); // Ensure cookies are updated when connection changes useEffect(() => { if (!connection) { return; } const token = connection.token; const data = connection.user; if (token) { Cookies.set('githubToken', token); Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' })); } if (data) { Cookies.set('githubUsername', data.login); } }, [connection]); // Add function to update rate limits const updateRateLimits = async (token: string) => { try { const response = await fetch('https://api.github.com/rate_limit', { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github.v3+json', }, }); if (response.ok) { const rateLimit = { limit: parseInt(response.headers.get('x-ratelimit-limit') || '0'), remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '0'), reset: parseInt(response.headers.get('x-ratelimit-reset') || '0'), }; setConnection((prev) => ({ ...prev, rateLimit, })); } } catch (error) { console.error('Failed to fetch rate limits:', error); } }; // Add effect to update rate limits periodically useEffect(() => { let interval: NodeJS.Timeout; if (connection.token && connection.user) { updateRateLimits(connection.token); interval = setInterval(() => updateRateLimits(connection.token), 60000); // Update every minute } return () => { if (interval) { clearInterval(interval); } }; }, [connection.token, connection.user]); if (isLoading || isConnecting || isFetchingStats) { return ; } const handleConnect = async (event: React.FormEvent) => { event.preventDefault(); setIsConnecting(true); try { // Update the ref with the current state value before connecting tokenTypeRef.current = connection.tokenType; /* * Save token type to localStorage even before connecting * This ensures the token type is persisted even if connection fails */ localStorage.setItem( 'github_connection', JSON.stringify({ user: null, token: connection.token, tokenType: connection.tokenType, }), ); // Attempt to fetch the user info which validates the token await fetchGithubUser(connection.token); toast.success('Connected to GitHub successfully'); } catch (error) { console.error('Failed to connect to GitHub:', error); // Reset connection state on failure setConnection({ user: null, token: connection.token, tokenType: connection.tokenType }); toast.error(`Failed to connect to GitHub: ${error instanceof Error ? error.message : 'Unknown error'}`); } finally { setIsConnecting(false); } }; const handleDisconnect = () => { localStorage.removeItem('github_connection'); // Remove all GitHub-related cookies Cookies.remove('githubToken'); Cookies.remove('githubUsername'); Cookies.remove('git:github.com'); // Reset the token type ref tokenTypeRef.current = 'classic'; setConnection({ user: null, token: '', tokenType: 'classic' }); toast.success('Disconnected from GitHub'); }; return (

GitHub Connection

{!connection.user && (

Tip: You can also set the{' '} VITE_GITHUB_ACCESS_TOKEN {' '} environment variable to connect automatically.

For fine-grained tokens, also set{' '} VITE_GITHUB_TOKEN_TYPE=fine-grained

)}
setConnection((prev) => ({ ...prev, token: e.target.value }))} disabled={isConnecting || !!connection.user} placeholder={`Enter your GitHub ${ connection.tokenType === 'classic' ? 'personal access token' : 'fine-grained token' }`} className={classNames( 'w-full px-3 py-2 rounded-lg text-sm', 'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1', 'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor', 'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary', 'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent', 'disabled:opacity-50', )} />
Get your token
Required scopes:{' '} {connection.tokenType === 'classic' ? 'repo, read:org, read:user' : 'Repository access, Organization access'}
{!connection.user ? ( ) : ( <>
Connected to GitHub using{' '} {connection.tokenType === 'classic' ? 'PAT' : 'Fine-grained Token'}
{connection.rateLimit && (
API Limit: {connection.rateLimit.remaining.toLocaleString()}/ {connection.rateLimit.limit.toLocaleString()} • Resets in{' '} {Math.max(0, Math.floor((connection.rateLimit.reset * 1000 - Date.now()) / 60000))} min
)}
)}
{connection.user && connection.stats && (
{connection.user.login}

{connection.user.name || connection.user.login}

{connection.user.login}

GitHub Stats
{/* Languages Section */}

Top Languages

{Object.entries(connection.stats.languages) .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([language]) => ( {language} ))}
{/* Additional Stats */}
{[ { label: 'Member Since', value: new Date(connection.user.created_at).toLocaleDateString(), }, { label: 'Public Gists', value: connection.stats.publicGists, }, { label: 'Organizations', value: connection.stats.organizations ? connection.stats.organizations.length : 0, }, { label: 'Languages', value: Object.keys(connection.stats.languages).length, }, ].map((stat, index) => (
{stat.label} {stat.value}
))}
{/* Repository Stats */}
Repository Stats
{[ { label: 'Public Repos', value: connection.stats.publicRepos, }, { label: 'Private Repos', value: connection.stats.privateRepos, }, ].map((stat, index) => (
{stat.label} {stat.value}
))}
Contribution Stats
{[ { label: 'Stars', value: connection.stats.stars || 0, icon: 'i-ph:star', iconColor: 'text-bolt-elements-icon-warning', }, { label: 'Forks', value: connection.stats.forks || 0, icon: 'i-ph:git-fork', iconColor: 'text-bolt-elements-icon-info', }, { label: 'Followers', value: connection.stats.followers || 0, icon: 'i-ph:users', iconColor: 'text-bolt-elements-icon-success', }, ].map((stat, index) => (
{stat.label}
{stat.value}
))}
Gists
{[ { label: 'Public', value: connection.stats.publicGists, }, { label: 'Private', value: connection.stats.privateGists || 0, }, ].map((stat, index) => (
{stat.label} {stat.value}
))}
Last updated: {new Date(connection.stats.lastUpdated).toLocaleString()}
{/* Repositories Section */}