jo3t / app /lib /services /gitlabApiService.ts
samifalouti1
Fresh start without binaries
55d48a7
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() {
// Log token format for debugging
console.log('GitLab API token info:', {
tokenLength: this._token.length,
tokenPrefix: this._token.substring(0, 10) + '...',
tokenType: this._token.startsWith('glpat-') ? 'personal-access-token' : 'unknown',
});
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) {
let errorMessage = `Failed to fetch user: ${response.status}`;
// Provide more specific error messages based on status code
if (response.status === 401) {
errorMessage =
'401 Unauthorized: Invalid or expired GitLab access token. Please check your token and ensure it has the required scopes (api, read_repository).';
} else if (response.status === 403) {
errorMessage = '403 Forbidden: GitLab access token does not have sufficient permissions.';
} else if (response.status === 404) {
errorMessage = '404 Not Found: GitLab API endpoint not found. Please check your GitLab URL configuration.';
} else if (response.status === 429) {
errorMessage = '429 Too Many Requests: GitLab API rate limit exceeded. Please try again later.';
}
// Try to get more details from response body
try {
const errorData = (await response.json()) as any;
if (errorData.message) {
errorMessage += ` Details: ${errorData.message}`;
}
} catch {
// If we can't parse the error response, continue with the default message
}
throw new Error(errorMessage);
}
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) {
let errorMessage = `Failed to fetch projects: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
console.error('GitLab projects API error:', errorData);
errorMessage = `Failed to fetch projects: ${JSON.stringify(errorData)}`;
} catch (parseError) {
console.error('Could not parse GitLab error response:', parseError);
}
throw new Error(errorMessage);
}
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> {
// Sanitize project name to ensure it's valid for GitLab
const sanitizedName = name
.replace(/[^a-zA-Z0-9-_.]/g, '-') // Replace invalid chars with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
.toLowerCase();
const response = await this._request('/projects', {
method: 'POST',
body: JSON.stringify({
name: sanitizedName,
path: sanitizedName, // Explicitly set path to match name
visibility: isPrivate ? 'private' : 'public',
initialize_with_readme: false, // Don't initialize with README to avoid conflicts
default_branch: 'main', // Explicitly set default branch
description: `Project created from Bolt.diy`,
}),
});
if (!response.ok) {
let errorMessage = `Failed to create project: ${response.status} ${response.statusText}`;
try {
const errorData = (await response.json()) as any;
if (errorData.message) {
if (typeof errorData.message === 'object') {
// Handle validation errors
const messages = Object.entries(errorData.message as Record<string, any>)
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : value}`)
.join('; ');
errorMessage = `Failed to create project: ${messages}`;
} else {
errorMessage = `Failed to create project: ${errorData.message}`;
}
}
} catch (parseError) {
console.error('Could not parse error response:', parseError);
}
throw new Error(errorMessage);
}
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 {
// Double encode the project path as GitLab API requires it
const encodedPath = encodeURIComponent(projectPath);
const response = await this._request(`/projects/${encodedPath}`);
if (response.ok) {
return await response.json();
}
if (response.status === 404) {
console.log(`Project not found: ${projectPath}`);
return null;
}
const errorText = await response.text();
console.error(`Failed to fetch project ${projectPath}:`, response.status, errorText);
throw new Error(`Failed to fetch project: ${response.status} ${response.statusText}`);
} catch (error) {
if (error instanceof Error && (error.message.includes('404') || error.message.includes('Not Found'))) {
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) {
// Wait a moment for the project to be fully created
await new Promise((resolve) => setTimeout(resolve, 1000));
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,
};
try {
await this.commitFiles(project.id, commitRequest);
} catch (error) {
console.error('Failed to commit files to new project:', error);
/*
* Don't throw the error, as the project was created successfully
* The user can still access it and add files manually
*/
}
}
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 };