Files
bolt-diy/app/lib/services/githubApiService.ts
Stijnus 3ea96506ea feat: gitLab Integration Implementation / github refactor / overal improvements (#1963)
* Add GitLab integration components

Introduced PushToGitLabDialog and GitlabConnection components to handle GitLab project connections and push functionality. Includes user authentication, project handling, and UI for seamless integration with GitLab.

* Add components for GitLab connection and push dialog

Introduce `GitlabConnection` and `PushToGitLabDialog` components to handle GitLab integration. These components allow users to connect their GitLab account, manage recent projects, and push code to a GitLab repository with detailed configurations and feedback.

* Fix GitLab personal access tokens link to use correct URL

* Update GitHub push call to use new pushToRepository method

* Enhance GitLab integration with performance improvements

- Add comprehensive caching system for repositories and user data
- Implement pagination and search/filter functionality with debouncing
- Add skeleton loaders and improved loading states
- Implement retry logic for API calls with exponential backoff
- Add background refresh capabilities
- Improve error handling and user feedback
- Optimize API calls to reduce loading times

* feat: implement GitLab integration with connection management and repository handling

- Add GitLab connection UI components
- Implement GitLab API service for repository operations
- Add GitLab connection store for state management
- Update existing connection components (Vercel, Netlify)
- Add repository listing and statistics display
- Refactor GitLab components into organized folder structure

* fix: resolve GitLab deployment issues and improve user experience

- Fix DialogTitle accessibility warnings for screen readers
- Remove CORS-problematic attributes from avatar images to prevent loading errors
- Enhance GitLab API error handling with detailed error messages
- Fix project creation settings to prevent initial commit conflicts
- Add automatic GitLab connection state initialization on app startup
- Improve deployment dialog UI with better error handling and user feedback
- Add GitLab deployment source type to action runner system
- Clean up deprecated push dialog files and consolidate deployment components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: implement GitHub clone repository dialog functionality

This commit fixes the missing GitHub repository selection dialog in the "Clone a repo" feature
by implementing the same elegant interface pattern used by GitLab.

Key Changes:
- Added onCloneRepository prop support to GitHubConnection component
- Updated RepositoryCard to generate proper GitHub clone URLs (https://github.com/{full_name}.git)
- Implemented full GitHub repository selection dialog in GitCloneButton.tsx
- Added proper dialog close handling after successful clone operations
- Maintained existing GitHub connection settings page functionality

Technical Details:
- Follows same component patterns as GitLab implementation
- Uses proper TypeScript interfaces for clone URL handling
- Includes professional dialog styling with loading states
- Supports repository search, pagination, and authentication flow

The GitHub clone experience now matches GitLab's functionality, providing users with
a unified and intuitive repository selection interface across both providers.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Clean up unused connection components

- Remove ConnectionForm.tsx (unused GitHub form component)
- Remove CreateBranchDialog.tsx (unused branch creation dialog)
- Remove RepositoryDialogContext.tsx (unused context provider)
- Remove empty components/ directory

These files were not referenced anywhere in the codebase and were leftover from development.

* Remove environment variables info section from ConnectionsTab

- Remove collapsible environment variables section
- Clean up unused state and imports
- Simplify the connections tab UI

* Reorganize connections folder structure

- Create netlify/ folder and move NetlifyConnection.tsx
- Create vercel/ folder and move VercelConnection.tsx
- Add index.ts files for both netlify and vercel folders
- Update imports in ConnectionsTab.tsx to use new folder structure
- All connection components now follow consistent folder organization

---------

Co-authored-by: Hayat Bourgi <hayat.bourgi@montyholding.com>
Co-authored-by: Hayat55 <53140162+Hayat55@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-05 14:01:33 +02:00

339 lines
9.3 KiB
TypeScript

import type {
GitHubUserResponse,
GitHubRepoInfo,
GitHubEvent,
GitHubStats,
GitHubLanguageStats,
GitHubRateLimits,
} from '~/types/GitHub';
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
interface CacheEntry<T> {
data: T;
timestamp: number;
expiresAt: number;
}
class GitHubCache {
private _cache = new Map<string, CacheEntry<any>>();
set<T>(key: string, data: T, duration = CACHE_DURATION): void {
const timestamp = Date.now();
this._cache.set(key, {
data,
timestamp,
expiresAt: timestamp + duration,
});
}
get<T>(key: string): T | null {
const entry = this._cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this._cache.delete(key);
return null;
}
return entry.data;
}
clear(): void {
this._cache.clear();
}
isExpired(key: string): boolean {
const entry = this._cache.get(key);
return !entry || Date.now() > entry.expiresAt;
}
delete(key: string): void {
this._cache.delete(key);
}
}
class GitHubApiService {
private _cache = new GitHubCache();
private _baseUrl = 'https://api.github.com';
private async _makeRequest<T>(
endpoint: string,
token: string,
tokenType: 'classic' | 'fine-grained' = 'classic',
options: RequestInit = {},
): Promise<{ data: T; rateLimit?: GitHubRateLimits }> {
const authHeader = tokenType === 'classic' ? `token ${token}` : `Bearer ${token}`;
const response = await fetch(`${this._baseUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: authHeader,
'User-Agent': 'bolt.diy-app',
...options.headers,
},
});
// Extract rate limit information
const rateLimit: GitHubRateLimits = {
limit: parseInt(response.headers.get('x-ratelimit-limit') || '5000'),
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '5000'),
reset: new Date(parseInt(response.headers.get('x-ratelimit-reset') || '0') * 1000),
used: parseInt(response.headers.get('x-ratelimit-used') || '0'),
};
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`GitHub API Error (${response.status}): ${response.statusText}. ${errorBody}`);
}
const data = (await response.json()) as T;
return { data, rateLimit };
}
async fetchUser(
token: string,
_tokenType: 'classic' | 'fine-grained' = 'classic',
): Promise<{
user: GitHubUserResponse;
rateLimit: GitHubRateLimits;
}> {
const cacheKey = `user:${token.slice(0, 8)}`;
const cached = this._cache.get<{ user: GitHubUserResponse; rateLimit: GitHubRateLimits }>(cacheKey);
if (cached) {
return cached;
}
try {
// Use server-side API endpoint for user validation
const response = await fetch('/api/system/git-info?action=getUser', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`GitHub API Error (${response.status}): ${response.statusText}`);
}
// Get rate limit information from headers
const rateLimit: GitHubRateLimits = {
limit: parseInt(response.headers.get('x-ratelimit-limit') || '5000'),
remaining: parseInt(response.headers.get('x-ratelimit-remaining') || '5000'),
reset: new Date(parseInt(response.headers.get('x-ratelimit-reset') || '0') * 1000),
used: parseInt(response.headers.get('x-ratelimit-used') || '0'),
};
const data = (await response.json()) as { user: GitHubUserResponse };
const user = data.user;
if (!user || !user.login) {
throw new Error('Invalid user data received');
}
const result = { user, rateLimit };
this._cache.set(cacheKey, result);
return result;
} catch (error) {
console.error('Failed to fetch GitHub user:', error);
throw error;
}
}
async fetchRepositories(token: string, tokenType: 'classic' | 'fine-grained' = 'classic'): Promise<GitHubRepoInfo[]> {
const cacheKey = `repos:${token.slice(0, 8)}`;
const cached = this._cache.get<GitHubRepoInfo[]>(cacheKey);
if (cached) {
return cached;
}
try {
let allRepos: any[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const { data: repos } = await this._makeRequest<any[]>(
`/user/repos?per_page=100&page=${page}`,
token,
tokenType,
);
allRepos = [...allRepos, ...repos];
hasMore = repos.length === 100;
page++;
}
const repositories: GitHubRepoInfo[] = allRepos.map((repo) => ({
id: repo.id.toString(),
name: repo.name,
full_name: repo.full_name,
html_url: repo.html_url,
description: repo.description || '',
stargazers_count: repo.stargazers_count || 0,
forks_count: repo.forks_count || 0,
default_branch: repo.default_branch || 'main',
updated_at: repo.updated_at,
language: repo.language || '',
languages_url: repo.languages_url,
private: repo.private || false,
topics: repo.topics || [],
}));
this._cache.set(cacheKey, repositories);
return repositories;
} catch (error) {
console.error('Failed to fetch GitHub repositories:', error);
throw error;
}
}
async fetchRecentActivity(
username: string,
token: string,
tokenType: 'classic' | 'fine-grained' = 'classic',
): Promise<GitHubEvent[]> {
const cacheKey = `activity:${username}:${token.slice(0, 8)}`;
const cached = this._cache.get<GitHubEvent[]>(cacheKey);
if (cached) {
return cached;
}
try {
const { data: events } = await this._makeRequest<any[]>(
`/users/${username}/events?per_page=10`,
token,
tokenType,
);
const recentActivity: GitHubEvent[] = events.slice(0, 5).map((event) => ({
id: event.id,
type: event.type,
created_at: event.created_at,
repo: {
name: event.repo?.name || '',
url: event.repo?.url || '',
},
payload: {
action: event.payload?.action,
ref: event.payload?.ref,
ref_type: event.payload?.ref_type,
description: event.payload?.description,
},
}));
this._cache.set(cacheKey, recentActivity);
return recentActivity;
} catch (error) {
console.error('Failed to fetch GitHub recent activity:', error);
throw error;
}
}
async fetchRepositoryLanguages(languagesUrl: string, token: string): Promise<GitHubLanguageStats> {
const cacheKey = `languages:${languagesUrl}`;
const cached = this._cache.get<GitHubLanguageStats>(cacheKey);
if (cached) {
return cached;
}
try {
const response = await fetch(languagesUrl, {
headers: {
Authorization: `token ${token}`,
'User-Agent': 'bolt.diy-app',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch languages: ${response.statusText}`);
}
const languages = (await response.json()) as GitHubLanguageStats;
this._cache.set(cacheKey, languages);
return languages;
} catch (error) {
console.error('Failed to fetch repository languages:', error);
return {};
}
}
async fetchStats(token: string, tokenType: 'classic' | 'fine-grained' = 'classic'): Promise<GitHubStats> {
try {
// Fetch user data
const { user } = await this.fetchUser(token, tokenType);
// Fetch repositories
const repositories = await this.fetchRepositories(token, tokenType);
// Fetch recent activity
const recentActivity = await this.fetchRecentActivity(user.login, token, tokenType);
// Calculate stats
const totalStars = repositories.reduce((sum, repo) => sum + repo.stargazers_count, 0);
const totalForks = repositories.reduce((sum, repo) => sum + repo.forks_count, 0);
const privateRepos = repositories.filter((repo) => repo.private).length;
// Calculate language statistics
const languages: GitHubLanguageStats = {};
for (const repo of repositories) {
if (repo.language) {
languages[repo.language] = (languages[repo.language] || 0) + 1;
}
}
const stats: GitHubStats = {
repos: repositories,
totalStars,
totalForks,
organizations: [], // TODO: Implement organizations fetching if needed
recentActivity,
languages,
totalGists: user.public_gists || 0,
publicRepos: user.public_repos || 0,
privateRepos,
stars: totalStars,
forks: totalForks,
followers: user.followers || 0,
publicGists: user.public_gists || 0,
privateGists: 0, // GitHub API doesn't provide private gists count directly
lastUpdated: new Date().toISOString(),
};
return stats;
} catch (error) {
console.error('Failed to fetch GitHub stats:', error);
throw error;
}
}
clearCache(): void {
this._cache.clear();
}
clearUserCache(token: string): void {
const keyPrefix = token.slice(0, 8);
this._cache.delete(`user:${keyPrefix}`);
this._cache.delete(`repos:${keyPrefix}`);
}
}
export const gitHubApiService = new GitHubApiService();