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>
This commit is contained in:
@@ -506,7 +506,7 @@ export class ActionRunner {
|
||||
details?: {
|
||||
url?: string;
|
||||
error?: string;
|
||||
source?: 'netlify' | 'vercel' | 'github';
|
||||
source?: 'netlify' | 'vercel' | 'github' | 'gitlab';
|
||||
},
|
||||
): void {
|
||||
if (!this.onDeployAlert) {
|
||||
|
||||
338
app/lib/services/githubApiService.ts
Normal file
338
app/lib/services/githubApiService.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
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();
|
||||
421
app/lib/services/gitlabApiService.ts
Normal file
421
app/lib/services/gitlabApiService.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
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 };
|
||||
226
app/lib/stores/githubConnection.ts
Normal file
226
app/lib/stores/githubConnection.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { atom, computed } from 'nanostores';
|
||||
import Cookies from 'js-cookie';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { gitHubApiService } from '~/lib/services/githubApiService';
|
||||
import { calculateStatsSummary } from '~/utils/githubStats';
|
||||
import type { GitHubConnection } from '~/types/GitHub';
|
||||
|
||||
// Auto-connect using environment variable
|
||||
const envToken = import.meta.env?.VITE_GITHUB_ACCESS_TOKEN;
|
||||
const envTokenType = import.meta.env?.VITE_GITHUB_TOKEN_TYPE;
|
||||
|
||||
const githubConnectionAtom = atom<GitHubConnection>({
|
||||
user: null,
|
||||
token: envToken || '',
|
||||
tokenType:
|
||||
envTokenType === 'classic' || envTokenType === 'fine-grained'
|
||||
? (envTokenType as 'classic' | 'fine-grained')
|
||||
: 'classic',
|
||||
});
|
||||
|
||||
// Initialize connection from localStorage on startup
|
||||
function initializeConnection() {
|
||||
try {
|
||||
const savedConnection = localStorage.getItem('github_connection');
|
||||
|
||||
if (savedConnection) {
|
||||
const parsed = JSON.parse(savedConnection);
|
||||
|
||||
// Ensure tokenType is set
|
||||
if (!parsed.tokenType) {
|
||||
parsed.tokenType = 'classic';
|
||||
}
|
||||
|
||||
// Only set if we have a valid user
|
||||
if (parsed.user) {
|
||||
githubConnectionAtom.set(parsed);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing GitHub connection:', error);
|
||||
localStorage.removeItem('github_connection');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load (client-side only)
|
||||
if (typeof window !== 'undefined') {
|
||||
initializeConnection();
|
||||
}
|
||||
|
||||
// Computed store for checking if connected
|
||||
export const isGitHubConnected = computed(githubConnectionAtom, (connection) => !!connection.user);
|
||||
|
||||
// Computed store for GitHub stats summary
|
||||
export const githubStatsSummary = computed(githubConnectionAtom, (connection) => {
|
||||
if (!connection.stats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return calculateStatsSummary(connection.stats);
|
||||
});
|
||||
|
||||
// Connection status atoms
|
||||
export const isGitHubConnecting = atom(false);
|
||||
export const isGitHubLoadingStats = atom(false);
|
||||
|
||||
// GitHub connection store methods
|
||||
export const githubConnectionStore = {
|
||||
// Get current connection
|
||||
get: () => githubConnectionAtom.get(),
|
||||
|
||||
// Connect to GitHub
|
||||
async connect(token: string, tokenType: 'classic' | 'fine-grained' = 'classic'): Promise<void> {
|
||||
if (isGitHubConnecting.get()) {
|
||||
throw new Error('Connection already in progress');
|
||||
}
|
||||
|
||||
isGitHubConnecting.set(true);
|
||||
|
||||
try {
|
||||
// Fetch user data
|
||||
const { user, rateLimit } = await gitHubApiService.fetchUser(token, tokenType);
|
||||
|
||||
// Create connection object
|
||||
const connection: GitHubConnection = {
|
||||
user,
|
||||
token,
|
||||
tokenType,
|
||||
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(connection));
|
||||
|
||||
// Update atom
|
||||
githubConnectionAtom.set(connection);
|
||||
|
||||
logStore.logInfo('Connected to GitHub', {
|
||||
type: 'system',
|
||||
message: `Connected to GitHub as ${user.login}`,
|
||||
});
|
||||
|
||||
// Fetch stats in background
|
||||
this.fetchStats().catch((error) => {
|
||||
console.error('Failed to fetch initial GitHub stats:', error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to GitHub:', error);
|
||||
logStore.logError(`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
|
||||
type: 'system',
|
||||
message: 'GitHub authentication failed',
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
isGitHubConnecting.set(false);
|
||||
}
|
||||
},
|
||||
|
||||
// Disconnect from GitHub
|
||||
disconnect(): void {
|
||||
// Clear atoms
|
||||
githubConnectionAtom.set({
|
||||
user: null,
|
||||
token: '',
|
||||
tokenType: 'classic',
|
||||
});
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('github_connection');
|
||||
|
||||
// Clear cookies
|
||||
Cookies.remove('githubUsername');
|
||||
Cookies.remove('githubToken');
|
||||
Cookies.remove('git:github.com');
|
||||
|
||||
// Clear API service cache
|
||||
gitHubApiService.clearCache();
|
||||
|
||||
logStore.logInfo('Disconnected from GitHub', {
|
||||
type: 'system',
|
||||
message: 'Disconnected from GitHub',
|
||||
});
|
||||
},
|
||||
|
||||
// Fetch GitHub stats
|
||||
async fetchStats(): Promise<void> {
|
||||
const connection = githubConnectionAtom.get();
|
||||
|
||||
if (!connection.user || !connection.token) {
|
||||
throw new Error('Not connected to GitHub');
|
||||
}
|
||||
|
||||
if (isGitHubLoadingStats.get()) {
|
||||
return; // Already loading
|
||||
}
|
||||
|
||||
isGitHubLoadingStats.set(true);
|
||||
|
||||
try {
|
||||
const stats = await gitHubApiService.fetchStats(connection.token, connection.tokenType);
|
||||
|
||||
// Update connection with stats
|
||||
const updatedConnection: GitHubConnection = {
|
||||
...connection,
|
||||
stats,
|
||||
};
|
||||
|
||||
// Update localStorage
|
||||
localStorage.setItem('github_connection', JSON.stringify(updatedConnection));
|
||||
|
||||
// Update atom
|
||||
githubConnectionAtom.set(updatedConnection);
|
||||
|
||||
logStore.logInfo('GitHub stats refreshed', {
|
||||
type: 'system',
|
||||
message: 'Successfully refreshed GitHub statistics',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub stats:', error);
|
||||
|
||||
// If the error is due to expired token, disconnect
|
||||
if (error instanceof Error && error.message.includes('401')) {
|
||||
logStore.logError('GitHub token has expired', {
|
||||
type: 'system',
|
||||
message: 'GitHub token has expired. Please reconnect your account.',
|
||||
});
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
isGitHubLoadingStats.set(false);
|
||||
}
|
||||
},
|
||||
|
||||
// Update token type
|
||||
updateTokenType(tokenType: 'classic' | 'fine-grained'): void {
|
||||
const connection = githubConnectionAtom.get();
|
||||
const updatedConnection = {
|
||||
...connection,
|
||||
tokenType,
|
||||
};
|
||||
|
||||
githubConnectionAtom.set(updatedConnection);
|
||||
localStorage.setItem('github_connection', JSON.stringify(updatedConnection));
|
||||
},
|
||||
|
||||
// Clear stats cache
|
||||
clearCache(): void {
|
||||
const connection = githubConnectionAtom.get();
|
||||
|
||||
if (connection.token) {
|
||||
gitHubApiService.clearUserCache(connection.token);
|
||||
}
|
||||
},
|
||||
|
||||
// Subscribe to connection changes
|
||||
subscribe: githubConnectionAtom.subscribe.bind(githubConnectionAtom),
|
||||
};
|
||||
|
||||
// Export the atom for direct access
|
||||
export { githubConnectionAtom };
|
||||
300
app/lib/stores/gitlabConnection.ts
Normal file
300
app/lib/stores/gitlabConnection.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { atom, computed } from 'nanostores';
|
||||
import Cookies from 'js-cookie';
|
||||
import { logStore } from '~/lib/stores/logs';
|
||||
import { GitLabApiService } from '~/lib/services/gitlabApiService';
|
||||
import { calculateStatsSummary } from '~/utils/gitlabStats';
|
||||
import type { GitLabConnection, GitLabStats } from '~/types/GitLab';
|
||||
|
||||
// Auto-connect using environment variable
|
||||
const envToken = import.meta.env?.VITE_GITLAB_ACCESS_TOKEN;
|
||||
|
||||
const gitlabConnectionAtom = atom<GitLabConnection>({
|
||||
user: null,
|
||||
token: envToken || '',
|
||||
tokenType: 'personal-access-token',
|
||||
});
|
||||
|
||||
const gitlabUrlAtom = atom('https://gitlab.com');
|
||||
|
||||
// Initialize connection from localStorage on startup
|
||||
function initializeConnection() {
|
||||
try {
|
||||
const savedConnection = localStorage.getItem('gitlab_connection');
|
||||
|
||||
if (savedConnection) {
|
||||
const parsed = JSON.parse(savedConnection);
|
||||
parsed.tokenType = 'personal-access-token';
|
||||
|
||||
if (parsed.gitlabUrl) {
|
||||
gitlabUrlAtom.set(parsed.gitlabUrl);
|
||||
}
|
||||
|
||||
// Only set if we have a valid user
|
||||
if (parsed.user) {
|
||||
gitlabConnectionAtom.set(parsed);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing GitLab connection:', error);
|
||||
localStorage.removeItem('gitlab_connection');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load (client-side only)
|
||||
if (typeof window !== 'undefined') {
|
||||
initializeConnection();
|
||||
}
|
||||
|
||||
// Computed store for checking if connected
|
||||
export const isGitLabConnected = computed(gitlabConnectionAtom, (connection) => !!connection.user);
|
||||
|
||||
// Computed store for current connection
|
||||
export const gitlabConnection = computed(gitlabConnectionAtom, (connection) => connection);
|
||||
|
||||
// Computed store for current user
|
||||
export const gitlabUser = computed(gitlabConnectionAtom, (connection) => connection.user);
|
||||
|
||||
// Computed store for current stats
|
||||
export const gitlabStats = computed(gitlabConnectionAtom, (connection) => connection.stats);
|
||||
|
||||
// Computed store for current URL
|
||||
export const gitlabUrl = computed(gitlabUrlAtom, (url) => url);
|
||||
|
||||
class GitLabConnectionStore {
|
||||
async connect(token: string, gitlabUrl = 'https://gitlab.com') {
|
||||
try {
|
||||
const apiService = new GitLabApiService(token, gitlabUrl);
|
||||
|
||||
// Test connection by fetching user
|
||||
const user = await apiService.getUser();
|
||||
|
||||
// Update state
|
||||
gitlabConnectionAtom.set({
|
||||
user,
|
||||
token,
|
||||
tokenType: 'personal-access-token',
|
||||
gitlabUrl,
|
||||
});
|
||||
|
||||
// Set cookies for client-side access
|
||||
Cookies.set('gitlabUsername', user.username);
|
||||
Cookies.set('gitlabToken', token);
|
||||
Cookies.set('git:gitlab.com', JSON.stringify({ username: user.username, password: token }));
|
||||
Cookies.set('gitlabUrl', gitlabUrl);
|
||||
|
||||
// Store connection details in localStorage
|
||||
localStorage.setItem(
|
||||
'gitlab_connection',
|
||||
JSON.stringify({
|
||||
user,
|
||||
token,
|
||||
tokenType: 'personal-access-token',
|
||||
gitlabUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
logStore.logInfo('Connected to GitLab', {
|
||||
type: 'system',
|
||||
message: `Connected to GitLab as ${user.username}`,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to GitLab:', error);
|
||||
|
||||
logStore.logError(`GitLab authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
|
||||
type: 'system',
|
||||
message: 'GitLab authentication failed',
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async fetchStats(_forceRefresh = false) {
|
||||
const connection = gitlabConnectionAtom.get();
|
||||
|
||||
if (!connection.user || !connection.token) {
|
||||
throw new Error('Not connected to GitLab');
|
||||
}
|
||||
|
||||
try {
|
||||
const apiService = new GitLabApiService(connection.token, connection.gitlabUrl || 'https://gitlab.com');
|
||||
|
||||
// Fetch user data
|
||||
const userData = await apiService.getUser();
|
||||
|
||||
// Fetch projects
|
||||
const projects = await apiService.getProjects();
|
||||
|
||||
// Fetch events
|
||||
const events = await apiService.getEvents();
|
||||
|
||||
// Fetch groups
|
||||
const groups = await apiService.getGroups();
|
||||
|
||||
// Fetch snippets
|
||||
const snippets = await apiService.getSnippets();
|
||||
|
||||
// Calculate stats
|
||||
const stats: GitLabStats = calculateStatsSummary(projects, events, groups, snippets, userData);
|
||||
|
||||
// Update connection with stats
|
||||
gitlabConnectionAtom.set({
|
||||
...connection,
|
||||
stats,
|
||||
});
|
||||
|
||||
// Update localStorage
|
||||
const updatedConnection = { ...connection, stats };
|
||||
localStorage.setItem('gitlab_connection', JSON.stringify(updatedConnection));
|
||||
|
||||
return { success: true, stats };
|
||||
} catch (error) {
|
||||
console.error('Error fetching GitLab stats:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Remove cookies
|
||||
Cookies.remove('gitlabToken');
|
||||
Cookies.remove('gitlabUsername');
|
||||
Cookies.remove('git:gitlab.com');
|
||||
Cookies.remove('gitlabUrl');
|
||||
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('gitlab_connection');
|
||||
|
||||
// Reset state
|
||||
gitlabConnectionAtom.set({
|
||||
user: null,
|
||||
token: '',
|
||||
tokenType: 'personal-access-token',
|
||||
});
|
||||
|
||||
logStore.logInfo('Disconnected from GitLab', {
|
||||
type: 'system',
|
||||
message: 'Disconnected from GitLab',
|
||||
});
|
||||
}
|
||||
|
||||
loadSavedConnection() {
|
||||
try {
|
||||
const savedConnection = localStorage.getItem('gitlab_connection');
|
||||
|
||||
if (savedConnection) {
|
||||
const parsed = JSON.parse(savedConnection);
|
||||
parsed.tokenType = 'personal-access-token';
|
||||
|
||||
// Set GitLab URL if saved
|
||||
if (parsed.gitlabUrl) {
|
||||
gitlabUrlAtom.set(parsed.gitlabUrl);
|
||||
}
|
||||
|
||||
// Set connection
|
||||
gitlabConnectionAtom.set(parsed);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing saved GitLab connection:', error);
|
||||
localStorage.removeItem('gitlab_connection');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
setGitLabUrl(url: string) {
|
||||
gitlabUrlAtom.set(url);
|
||||
}
|
||||
|
||||
setToken(token: string) {
|
||||
gitlabConnectionAtom.set({
|
||||
...gitlabConnectionAtom.get(),
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-connect using environment token
|
||||
async autoConnect() {
|
||||
if (!envToken) {
|
||||
return { success: false, error: 'No GitLab token found in environment' };
|
||||
}
|
||||
|
||||
try {
|
||||
const apiService = new GitLabApiService(envToken);
|
||||
const user = await apiService.getUser();
|
||||
|
||||
// Update state
|
||||
gitlabConnectionAtom.set({
|
||||
user,
|
||||
token: envToken,
|
||||
tokenType: 'personal-access-token',
|
||||
gitlabUrl: 'https://gitlab.com',
|
||||
});
|
||||
|
||||
// Set cookies for client-side access
|
||||
Cookies.set('gitlabUsername', user.username);
|
||||
Cookies.set('gitlabToken', envToken);
|
||||
Cookies.set('git:gitlab.com', JSON.stringify({ username: user.username, password: envToken }));
|
||||
Cookies.set('gitlabUrl', 'https://gitlab.com');
|
||||
|
||||
// Store connection details in localStorage
|
||||
localStorage.setItem(
|
||||
'gitlab_connection',
|
||||
JSON.stringify({
|
||||
user,
|
||||
token: envToken,
|
||||
tokenType: 'personal-access-token',
|
||||
gitlabUrl: 'https://gitlab.com',
|
||||
}),
|
||||
);
|
||||
|
||||
logStore.logInfo('Auto-connected to GitLab', {
|
||||
type: 'system',
|
||||
message: `Auto-connected to GitLab as ${user.username}`,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-connect to GitLab:', error);
|
||||
|
||||
logStore.logError(`GitLab auto-connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
|
||||
type: 'system',
|
||||
message: 'GitLab auto-connection failed',
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const gitlabConnectionStore = new GitLabConnectionStore();
|
||||
|
||||
// Export hooks for React components
|
||||
export function useGitLabConnection() {
|
||||
return {
|
||||
connection: gitlabConnection,
|
||||
isConnected: isGitLabConnected,
|
||||
user: gitlabUser,
|
||||
stats: gitlabStats,
|
||||
gitlabUrl,
|
||||
connect: gitlabConnectionStore.connect.bind(gitlabConnectionStore),
|
||||
disconnect: gitlabConnectionStore.disconnect.bind(gitlabConnectionStore),
|
||||
fetchStats: gitlabConnectionStore.fetchStats.bind(gitlabConnectionStore),
|
||||
loadSavedConnection: gitlabConnectionStore.loadSavedConnection.bind(gitlabConnectionStore),
|
||||
setGitLabUrl: gitlabConnectionStore.setGitLabUrl.bind(gitlabConnectionStore),
|
||||
setToken: gitlabConnectionStore.setToken.bind(gitlabConnectionStore),
|
||||
autoConnect: gitlabConnectionStore.autoConnect.bind(gitlabConnectionStore),
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { toast } from 'react-toastify';
|
||||
// Initialize with stored connection or environment variable
|
||||
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
|
||||
const envToken = import.meta.env.VITE_NETLIFY_ACCESS_TOKEN;
|
||||
console.log('Netlify store: envToken loaded:', envToken ? '[TOKEN_EXISTS]' : '[NO_TOKEN]');
|
||||
|
||||
// If we have an environment token but no stored connection, initialize with the env token
|
||||
const initialConnection: NetlifyConnection = storedConnection
|
||||
@@ -24,11 +25,14 @@ export const isFetchingStats = atom<boolean>(false);
|
||||
export async function initializeNetlifyConnection() {
|
||||
const currentState = netlifyConnection.get();
|
||||
|
||||
// If we already have a connection, don't override it
|
||||
// If we already have a connection or no token, don't try to connect
|
||||
if (currentState.user || !envToken) {
|
||||
console.log('Netlify: Skipping auto-connect - user exists or no env token');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Netlify: Attempting auto-connection with env token');
|
||||
|
||||
try {
|
||||
isConnecting.set(true);
|
||||
|
||||
|
||||
@@ -3,15 +3,48 @@ import type { VercelConnection } from '~/types/vercel';
|
||||
import { logStore } from './logs';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
// Auto-connect using environment variable
|
||||
const envToken = import.meta.env?.VITE_VERCEL_ACCESS_TOKEN;
|
||||
|
||||
// Initialize with stored connection or defaults
|
||||
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('vercel_connection') : null;
|
||||
const initialConnection: VercelConnection = storedConnection
|
||||
? JSON.parse(storedConnection)
|
||||
: {
|
||||
let initialConnection: VercelConnection;
|
||||
|
||||
if (storedConnection) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedConnection);
|
||||
|
||||
// If we have a stored connection but no user and no token, clear it and use env token
|
||||
if (!parsed.user && !parsed.token && envToken) {
|
||||
console.log('Vercel store: Clearing incomplete saved connection, using env token');
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('vercel_connection');
|
||||
}
|
||||
|
||||
initialConnection = {
|
||||
user: null,
|
||||
token: envToken,
|
||||
stats: undefined,
|
||||
};
|
||||
} else {
|
||||
initialConnection = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing saved Vercel connection:', error);
|
||||
initialConnection = {
|
||||
user: null,
|
||||
token: '',
|
||||
token: envToken || '',
|
||||
stats: undefined,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
initialConnection = {
|
||||
user: null,
|
||||
token: envToken || '',
|
||||
stats: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export const vercelConnection = atom<VercelConnection>(initialConnection);
|
||||
export const isConnecting = atom<boolean>(false);
|
||||
@@ -28,6 +61,74 @@ export const updateVercelConnection = (updates: Partial<VercelConnection>) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-connect using environment token
|
||||
export async function autoConnectVercel() {
|
||||
console.log('autoConnectVercel called, envToken exists:', !!envToken);
|
||||
|
||||
if (!envToken) {
|
||||
console.error('No Vercel token found in environment');
|
||||
return { success: false, error: 'No Vercel token found in environment' };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Setting isConnecting to true');
|
||||
isConnecting.set(true);
|
||||
|
||||
// Test the connection
|
||||
console.log('Making API call to Vercel');
|
||||
|
||||
const response = await fetch('https://api.vercel.com/v2/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${envToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Vercel API response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vercel API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const userData = (await response.json()) as any;
|
||||
console.log('Vercel API response userData:', userData);
|
||||
|
||||
// Update connection
|
||||
console.log('Updating Vercel connection');
|
||||
updateVercelConnection({
|
||||
user: userData.user || userData,
|
||||
token: envToken,
|
||||
});
|
||||
|
||||
logStore.logInfo('Auto-connected to Vercel', {
|
||||
type: 'system',
|
||||
message: `Auto-connected to Vercel as ${userData.user?.username || userData.username}`,
|
||||
});
|
||||
|
||||
// Fetch stats
|
||||
console.log('Fetching Vercel stats');
|
||||
await fetchVercelStats(envToken);
|
||||
|
||||
console.log('Vercel auto-connection successful');
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-connect to Vercel:', error);
|
||||
logStore.logError(`Vercel auto-connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`, {
|
||||
type: 'system',
|
||||
message: 'Vercel auto-connection failed',
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
} finally {
|
||||
console.log('Setting isConnecting to false');
|
||||
isConnecting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVercelStats(token: string) {
|
||||
try {
|
||||
isFetchingStats.set(true);
|
||||
|
||||
@@ -674,197 +674,264 @@ export class WorkbenchStore {
|
||||
return syncedFiles;
|
||||
}
|
||||
|
||||
async pushToGitHub(
|
||||
async pushToRepository(
|
||||
provider: 'github' | 'gitlab',
|
||||
repoName: string,
|
||||
commitMessage?: string,
|
||||
githubUsername?: string,
|
||||
ghToken?: string,
|
||||
username?: string,
|
||||
token?: string,
|
||||
isPrivate: boolean = false,
|
||||
branchName: string = 'main',
|
||||
) {
|
||||
try {
|
||||
// Use cookies if username and token are not provided
|
||||
const githubToken = ghToken || Cookies.get('githubToken');
|
||||
const owner = githubUsername || Cookies.get('githubUsername');
|
||||
const isGitHub = provider === 'github';
|
||||
const isGitLab = provider === 'gitlab';
|
||||
|
||||
if (!githubToken || !owner) {
|
||||
throw new Error('GitHub token or username is not set in cookies or provided.');
|
||||
const authToken = token || Cookies.get(isGitHub ? 'githubToken' : 'gitlabToken');
|
||||
const owner = username || Cookies.get(isGitHub ? 'githubUsername' : 'gitlabUsername');
|
||||
|
||||
if (!authToken || !owner) {
|
||||
throw new Error(`${provider} token or username is not set in cookies or provided.`);
|
||||
}
|
||||
|
||||
// Log the isPrivate flag to verify it's being properly passed
|
||||
console.log(`pushToGitHub called with isPrivate=${isPrivate}`);
|
||||
|
||||
// Initialize Octokit with the auth token
|
||||
const octokit = new Octokit({ auth: githubToken });
|
||||
|
||||
// Check if the repository already exists before creating it
|
||||
let repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
|
||||
let visibilityJustChanged = false;
|
||||
|
||||
try {
|
||||
const resp = await octokit.repos.get({ owner, repo: repoName });
|
||||
repo = resp.data;
|
||||
console.log('Repository already exists, using existing repo');
|
||||
|
||||
// Check if we need to update visibility of existing repo
|
||||
if (repo.private !== isPrivate) {
|
||||
console.log(
|
||||
`Updating repository visibility from ${repo.private ? 'private' : 'public'} to ${isPrivate ? 'private' : 'public'}`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Update repository visibility using the update method
|
||||
const { data: updatedRepo } = await octokit.repos.update({
|
||||
owner,
|
||||
repo: repoName,
|
||||
private: isPrivate,
|
||||
});
|
||||
|
||||
console.log('Repository visibility updated successfully');
|
||||
repo = updatedRepo;
|
||||
visibilityJustChanged = true;
|
||||
|
||||
// Add a delay after changing visibility to allow GitHub to fully process the change
|
||||
console.log('Waiting for visibility change to propagate...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // 3 second delay
|
||||
} catch (visibilityError) {
|
||||
console.error('Failed to update repository visibility:', visibilityError);
|
||||
|
||||
// Continue with push even if visibility update fails
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'status' in error && error.status === 404) {
|
||||
// Repository doesn't exist, so create a new one
|
||||
console.log(`Creating new repository with private=${isPrivate}`);
|
||||
|
||||
// Create new repository with specified privacy setting
|
||||
const createRepoOptions = {
|
||||
name: repoName,
|
||||
private: isPrivate,
|
||||
auto_init: true,
|
||||
};
|
||||
|
||||
console.log('Create repo options:', createRepoOptions);
|
||||
|
||||
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser(createRepoOptions);
|
||||
|
||||
console.log('Repository created:', newRepo.html_url, 'Private:', newRepo.private);
|
||||
repo = newRepo;
|
||||
|
||||
// Add a small delay after creating a repository to allow GitHub to fully initialize it
|
||||
console.log('Waiting for repository to initialize...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 second delay
|
||||
} else {
|
||||
console.error('Cannot create repo:', error);
|
||||
throw error; // Some other error occurred
|
||||
}
|
||||
}
|
||||
|
||||
// Get all files
|
||||
const files = this.files.get();
|
||||
|
||||
if (!files || Object.keys(files).length === 0) {
|
||||
throw new Error('No files found to push');
|
||||
}
|
||||
|
||||
// Function to push files with retry logic
|
||||
const pushFilesToRepo = async (attempt = 1): Promise<string> => {
|
||||
const maxAttempts = 3;
|
||||
if (isGitHub) {
|
||||
// Initialize Octokit with the auth token
|
||||
const octokit = new Octokit({ auth: authToken });
|
||||
|
||||
// Check if the repository already exists before creating it
|
||||
let repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
|
||||
let visibilityJustChanged = false;
|
||||
|
||||
try {
|
||||
console.log(`Pushing files to repository (attempt ${attempt}/${maxAttempts})...`);
|
||||
const resp = await octokit.repos.get({ owner, repo: repoName });
|
||||
repo = resp.data;
|
||||
console.log('Repository already exists, using existing repo');
|
||||
|
||||
// Create blobs for each file
|
||||
const blobs = await Promise.all(
|
||||
Object.entries(files).map(async ([filePath, dirent]) => {
|
||||
if (dirent?.type === 'file' && dirent.content) {
|
||||
const { data: blob } = await octokit.git.createBlob({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
content: Buffer.from(dirent.content).toString('base64'),
|
||||
encoding: 'base64',
|
||||
});
|
||||
return { path: extractRelativePath(filePath), sha: blob.sha };
|
||||
}
|
||||
// Check if we need to update visibility of existing repo
|
||||
if (repo.private !== isPrivate) {
|
||||
console.log(
|
||||
`Updating repository visibility from ${repo.private ? 'private' : 'public'} to ${isPrivate ? 'private' : 'public'}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
try {
|
||||
// Update repository visibility using the update method
|
||||
const { data: updatedRepo } = await octokit.repos.update({
|
||||
owner,
|
||||
repo: repoName,
|
||||
private: isPrivate,
|
||||
});
|
||||
|
||||
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
|
||||
console.log('Repository visibility updated successfully');
|
||||
repo = updatedRepo;
|
||||
visibilityJustChanged = true;
|
||||
|
||||
if (validBlobs.length === 0) {
|
||||
throw new Error('No valid files to push');
|
||||
// Add a delay after changing visibility to allow GitHub to fully process the change
|
||||
console.log('Waiting for visibility change to propagate...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // 3 second delay
|
||||
} catch (visibilityError) {
|
||||
console.error('Failed to update repository visibility:', visibilityError);
|
||||
|
||||
// Continue with push even if visibility update fails
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh repository reference to ensure we have the latest data
|
||||
const repoRefresh = await octokit.repos.get({ owner, repo: repoName });
|
||||
repo = repoRefresh.data;
|
||||
|
||||
// Get the latest commit SHA (assuming main branch, update dynamically if needed)
|
||||
const { data: ref } = await octokit.git.getRef({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||||
});
|
||||
const latestCommitSha = ref.object.sha;
|
||||
|
||||
// Create a new tree
|
||||
const { data: newTree } = await octokit.git.createTree({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
base_tree: latestCommitSha,
|
||||
tree: validBlobs.map((blob) => ({
|
||||
path: blob!.path,
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
sha: blob!.sha,
|
||||
})),
|
||||
});
|
||||
|
||||
// Create a new commit
|
||||
const { data: newCommit } = await octokit.git.createCommit({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
message: commitMessage || 'Initial commit from your app',
|
||||
tree: newTree.sha,
|
||||
parents: [latestCommitSha],
|
||||
});
|
||||
|
||||
// Update the reference
|
||||
await octokit.git.updateRef({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||||
sha: newCommit.sha,
|
||||
});
|
||||
|
||||
console.log('Files successfully pushed to repository');
|
||||
|
||||
return repo.html_url;
|
||||
} catch (error) {
|
||||
console.error(`Error during push attempt ${attempt}:`, error);
|
||||
if (error instanceof Error && 'status' in error && error.status === 404) {
|
||||
// Repository doesn't exist, so create a new one
|
||||
console.log(`Creating new repository with private=${isPrivate}`);
|
||||
|
||||
// If we've just changed visibility and this is not our last attempt, wait and retry
|
||||
if ((visibilityJustChanged || attempt === 1) && attempt < maxAttempts) {
|
||||
const delayMs = attempt * 2000; // Increasing delay with each attempt
|
||||
console.log(`Waiting ${delayMs}ms before retry...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
// Create new repository with specified privacy setting
|
||||
const createRepoOptions = {
|
||||
name: repoName,
|
||||
private: isPrivate,
|
||||
auto_init: true,
|
||||
};
|
||||
|
||||
return pushFilesToRepo(attempt + 1);
|
||||
console.log('Create repo options:', createRepoOptions);
|
||||
|
||||
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser(createRepoOptions);
|
||||
|
||||
console.log('Repository created:', newRepo.html_url, 'Private:', newRepo.private);
|
||||
repo = newRepo;
|
||||
|
||||
// Add a small delay after creating a repository to allow GitHub to fully initialize it
|
||||
console.log('Waiting for repository to initialize...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // 2 second delay
|
||||
} else {
|
||||
console.error('Cannot create repo:', error);
|
||||
throw error; // Some other error occurred
|
||||
}
|
||||
|
||||
throw error; // Rethrow if we're out of attempts
|
||||
}
|
||||
};
|
||||
|
||||
// Execute the push function with retry logic
|
||||
const repoUrl = await pushFilesToRepo();
|
||||
// Get all files
|
||||
const files = this.files.get();
|
||||
|
||||
// Return the repository URL
|
||||
return repoUrl;
|
||||
if (!files || Object.keys(files).length === 0) {
|
||||
throw new Error('No files found to push');
|
||||
}
|
||||
|
||||
// Function to push files with retry logic
|
||||
const pushFilesToRepo = async (attempt = 1): Promise<string> => {
|
||||
const maxAttempts = 3;
|
||||
|
||||
try {
|
||||
console.log(`Pushing files to repository (attempt ${attempt}/${maxAttempts})...`);
|
||||
|
||||
// Create blobs for each file
|
||||
const blobs = await Promise.all(
|
||||
Object.entries(files).map(async ([filePath, dirent]) => {
|
||||
if (dirent?.type === 'file' && dirent.content) {
|
||||
const { data: blob } = await octokit.git.createBlob({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
content: Buffer.from(dirent.content).toString('base64'),
|
||||
encoding: 'base64',
|
||||
});
|
||||
return { path: extractRelativePath(filePath), sha: blob.sha };
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
|
||||
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
|
||||
|
||||
if (validBlobs.length === 0) {
|
||||
throw new Error('No valid files to push');
|
||||
}
|
||||
|
||||
// Refresh repository reference to ensure we have the latest data
|
||||
const repoRefresh = await octokit.repos.get({ owner, repo: repoName });
|
||||
repo = repoRefresh.data;
|
||||
|
||||
// Get the latest commit SHA (assuming main branch, update dynamically if needed)
|
||||
const { data: ref } = await octokit.git.getRef({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||||
});
|
||||
const latestCommitSha = ref.object.sha;
|
||||
|
||||
// Create a new tree
|
||||
const { data: newTree } = await octokit.git.createTree({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
base_tree: latestCommitSha,
|
||||
tree: validBlobs.map((blob) => ({
|
||||
path: blob!.path,
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
sha: blob!.sha,
|
||||
})),
|
||||
});
|
||||
|
||||
// Create a new commit
|
||||
const { data: newCommit } = await octokit.git.createCommit({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
message: commitMessage || 'Initial commit from your app',
|
||||
tree: newTree.sha,
|
||||
parents: [latestCommitSha],
|
||||
});
|
||||
|
||||
// Update the reference
|
||||
await octokit.git.updateRef({
|
||||
owner: repo.owner.login,
|
||||
repo: repo.name,
|
||||
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
|
||||
sha: newCommit.sha,
|
||||
});
|
||||
|
||||
console.log('Files successfully pushed to repository');
|
||||
|
||||
return repo.html_url;
|
||||
} catch (error) {
|
||||
console.error(`Error during push attempt ${attempt}:`, error);
|
||||
|
||||
// If we've just changed visibility and this is not our last attempt, wait and retry
|
||||
if ((visibilityJustChanged || attempt === 1) && attempt < maxAttempts) {
|
||||
const delayMs = attempt * 2000; // Increasing delay with each attempt
|
||||
console.log(`Waiting ${delayMs}ms before retry...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
|
||||
return pushFilesToRepo(attempt + 1);
|
||||
}
|
||||
|
||||
throw error; // Rethrow if we're out of attempts
|
||||
}
|
||||
};
|
||||
|
||||
// Execute the push function with retry logic
|
||||
const repoUrl = await pushFilesToRepo();
|
||||
|
||||
// Return the repository URL
|
||||
return repoUrl;
|
||||
}
|
||||
|
||||
if (isGitLab) {
|
||||
const { GitLabApiService: gitLabApiServiceClass } = await import('~/lib/services/gitlabApiService');
|
||||
const gitLabApiService = new gitLabApiServiceClass(authToken, 'https://gitlab.com');
|
||||
|
||||
// Check or create repo
|
||||
let repo = await gitLabApiService.getProject(owner, repoName);
|
||||
|
||||
if (!repo) {
|
||||
repo = await gitLabApiService.createProject(repoName, isPrivate);
|
||||
await new Promise((r) => setTimeout(r, 2000)); // Wait for repo initialization
|
||||
}
|
||||
|
||||
// Check if branch exists, create if not
|
||||
const branchRes = await gitLabApiService.getFile(repo.id, 'README.md', branchName).catch(() => null);
|
||||
|
||||
if (!branchRes || !branchRes.ok) {
|
||||
// Create branch from default
|
||||
await gitLabApiService.createBranch(repo.id, branchName, repo.default_branch);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
|
||||
const actions = Object.entries(files).reduce(
|
||||
(acc, [filePath, dirent]) => {
|
||||
if (dirent?.type === 'file' && dirent.content) {
|
||||
acc.push({
|
||||
action: 'create',
|
||||
file_path: extractRelativePath(filePath),
|
||||
content: dirent.content,
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as { action: 'create' | 'update'; file_path: string; content: string }[],
|
||||
);
|
||||
|
||||
// Check which files exist and update action accordingly
|
||||
for (const action of actions) {
|
||||
const fileCheck = await gitLabApiService.getFile(repo.id, action.file_path, branchName);
|
||||
|
||||
if (fileCheck.ok) {
|
||||
action.action = 'update';
|
||||
}
|
||||
}
|
||||
|
||||
// Commit all files
|
||||
await gitLabApiService.commitFiles(repo.id, {
|
||||
branch: branchName,
|
||||
commit_message: commitMessage || 'Commit multiple files',
|
||||
actions,
|
||||
});
|
||||
|
||||
return repo.web_url;
|
||||
}
|
||||
|
||||
// Should not reach here since we only handle GitHub and GitLab
|
||||
throw new Error(`Unsupported provider: ${provider}`);
|
||||
} catch (error) {
|
||||
console.error('Error pushing to GitHub:', error);
|
||||
console.error('Error pushing to repository:', error);
|
||||
throw error; // Rethrow the error for further handling
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user