Files
bolt-diy/app/lib/services/gitlabApiService.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

422 lines
11 KiB
TypeScript

import type {
GitLabUserResponse,
GitLabProjectInfo,
GitLabEvent,
GitLabGroupInfo,
GitLabProjectResponse,
GitLabCommitRequest,
} from '~/types/GitLab';
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
interface CacheEntry<T> {
data: T;
timestamp: number;
expiresAt: number;
}
class GitLabCache {
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;
}
}
const gitlabCache = new GitLabCache();
async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3): Promise<Response> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
// Don't retry on client errors (4xx) except 429 (rate limit)
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
return response;
}
// Retry on server errors (5xx) and rate limits
if (response.status >= 500 || response.status === 429) {
if (attempt === maxRetries) {
return response;
}
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt === maxRetries) {
throw lastError;
}
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
throw lastError!;
}
export class GitLabApiService {
private _baseUrl: string;
private _token: string;
constructor(token: string, baseUrl = 'https://gitlab.com') {
this._token = token;
this._baseUrl = baseUrl;
}
private get _headers() {
return {
'Content-Type': 'application/json',
'PRIVATE-TOKEN': this._token,
};
}
private async _request(endpoint: string, options: RequestInit = {}): Promise<Response> {
const url = `${this._baseUrl}/api/v4${endpoint}`;
return fetchWithRetry(url, {
...options,
headers: {
...this._headers,
...options.headers,
},
});
}
async getUser(): Promise<GitLabUserResponse> {
const response = await this._request('/user');
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
const user: GitLabUserResponse = await response.json();
// Get rate limit information from headers if available
const rateLimit = {
limit: parseInt(response.headers.get('ratelimit-limit') || '0'),
remaining: parseInt(response.headers.get('ratelimit-remaining') || '0'),
reset: parseInt(response.headers.get('ratelimit-reset') || '0'),
};
// Handle different avatar URL fields that GitLab might return
const processedUser = {
...user,
avatar_url: user.avatar_url || (user as any).avatarUrl || (user as any).profile_image_url || null,
};
return { ...processedUser, rateLimit } as GitLabUserResponse & { rateLimit: typeof rateLimit };
}
async getProjects(membership = true, minAccessLevel = 20, perPage = 50): Promise<GitLabProjectInfo[]> {
const cacheKey = `projects_${this._token}_${membership}_${minAccessLevel}`;
const cached = gitlabCache.get<GitLabProjectInfo[]>(cacheKey);
if (cached) {
return cached;
}
let allProjects: any[] = [];
let page = 1;
const maxPages = 10; // Limit to prevent excessive API calls
while (page <= maxPages) {
const response = await this._request(
`/projects?membership=${membership}&min_access_level=${minAccessLevel}&per_page=${perPage}&page=${page}&order_by=updated_at&sort=desc`,
);
if (!response.ok) {
throw new Error(`Failed to fetch projects: ${response.statusText}`);
}
const projects: any[] = await response.json();
if (projects.length === 0) {
break;
}
allProjects = [...allProjects, ...projects];
// Break if we have enough projects for initial load
if (allProjects.length >= 100) {
break;
}
page++;
}
// Transform to our interface
const transformedProjects: GitLabProjectInfo[] = allProjects.map((project: any) => ({
id: project.id,
name: project.name,
path_with_namespace: project.path_with_namespace,
description: project.description,
http_url_to_repo: project.http_url_to_repo,
star_count: project.star_count,
forks_count: project.forks_count,
default_branch: project.default_branch,
updated_at: project.updated_at,
visibility: project.visibility,
}));
gitlabCache.set(cacheKey, transformedProjects);
return transformedProjects;
}
async getEvents(perPage = 10): Promise<GitLabEvent[]> {
const response = await this._request(`/events?per_page=${perPage}`);
if (!response.ok) {
throw new Error(`Failed to fetch events: ${response.statusText}`);
}
const events: any[] = await response.json();
return events.slice(0, 5).map((event: any) => ({
id: event.id,
action_name: event.action_name,
project_id: event.project_id,
project: event.project,
created_at: event.created_at,
}));
}
async getGroups(minAccessLevel = 10): Promise<GitLabGroupInfo[]> {
const response = await this._request(`/groups?min_access_level=${minAccessLevel}`);
if (response.ok) {
return await response.json();
}
return [];
}
async getSnippets(): Promise<any[]> {
const response = await this._request('/snippets');
if (response.ok) {
return await response.json();
}
return [];
}
async createProject(name: string, isPrivate: boolean = false): Promise<GitLabProjectResponse> {
const response = await this._request('/projects', {
method: 'POST',
body: JSON.stringify({
name,
visibility: isPrivate ? 'private' : 'public',
initialize_with_readme: false, // Don't initialize with README to avoid conflicts
default_branch: 'main', // Explicitly set default branch
}),
});
if (!response.ok) {
throw new Error(`Failed to create project: ${response.statusText}`);
}
return await response.json();
}
async getProject(owner: string, name: string): Promise<GitLabProjectResponse | null> {
const response = await this._request(`/projects/${encodeURIComponent(`${owner}/${name}`)}`);
if (response.ok) {
return await response.json();
}
return null;
}
async createBranch(projectId: number, branchName: string, ref: string): Promise<any> {
const response = await this._request(`/projects/${projectId}/repository/branches`, {
method: 'POST',
body: JSON.stringify({
branch: branchName,
ref,
}),
});
if (!response.ok) {
throw new Error(`Failed to create branch: ${response.statusText}`);
}
return await response.json();
}
async commitFiles(projectId: number, commitRequest: GitLabCommitRequest): Promise<any> {
const response = await this._request(`/projects/${projectId}/repository/commits`, {
method: 'POST',
body: JSON.stringify(commitRequest),
});
if (!response.ok) {
let errorMessage = `Failed to commit files: ${response.status} ${response.statusText}`;
try {
const errorData = (await response.json()) as { message?: string; error?: string };
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// If JSON parsing fails, keep the default error message
}
throw new Error(errorMessage);
}
return await response.json();
}
async getFile(projectId: number, filePath: string, ref: string): Promise<Response> {
return this._request(`/projects/${projectId}/repository/files/${encodeURIComponent(filePath)}?ref=${ref}`);
}
async getProjectByPath(projectPath: string): Promise<GitLabProjectResponse | null> {
try {
const response = await this._request(`/projects/${encodeURIComponent(projectPath)}`);
if (response.ok) {
return await response.json();
}
if (response.status === 404) {
return null;
}
throw new Error(`Failed to fetch project: ${response.status} ${response.statusText}`);
} catch (error) {
if (error instanceof Error && error.message.includes('404')) {
return null;
}
throw error;
}
}
async updateProjectVisibility(projectId: number, visibility: 'public' | 'private'): Promise<void> {
const response = await this._request(`/projects/${projectId}`, {
method: 'PUT',
body: JSON.stringify({ visibility }),
});
if (!response.ok) {
throw new Error(`Failed to update project visibility: ${response.status} ${response.statusText}`);
}
}
async createProjectWithFiles(
name: string,
isPrivate: boolean,
files: Record<string, string>,
): Promise<GitLabProjectResponse> {
// Create the project first
const project = await this.createProject(name, isPrivate);
// If we have files to commit, commit them
if (Object.keys(files).length > 0) {
const actions = Object.entries(files).map(([filePath, content]) => ({
action: 'create' as const,
file_path: filePath,
content,
}));
const commitRequest: GitLabCommitRequest = {
branch: 'main',
commit_message: 'Initial commit from Bolt.diy',
actions,
};
await this.commitFiles(project.id, commitRequest);
}
return project;
}
async updateProjectWithFiles(projectId: number, files: Record<string, string>): Promise<void> {
if (Object.keys(files).length === 0) {
return;
}
// For existing projects, we need to determine which files exist and which are new
const actions = Object.entries(files).map(([filePath, content]) => ({
action: 'create' as const, // Start with create, we'll handle conflicts in the API response
file_path: filePath,
content,
}));
const commitRequest: GitLabCommitRequest = {
branch: 'main',
commit_message: 'Update from Bolt.diy',
actions,
};
try {
await this.commitFiles(projectId, commitRequest);
} catch (error) {
// If we get file conflicts, retry with update actions
if (error instanceof Error && error.message.includes('already exists')) {
const updateActions = Object.entries(files).map(([filePath, content]) => ({
action: 'update' as const,
file_path: filePath,
content,
}));
const updateCommitRequest: GitLabCommitRequest = {
branch: 'main',
commit_message: 'Update from Bolt.diy',
actions: updateActions,
};
await this.commitFiles(projectId, updateCommitRequest);
} else {
throw error;
}
}
}
}
export { gitlabCache };