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:
Stijnus
2025-09-05 14:01:33 +02:00
committed by GitHub
parent 8a685603be
commit 3ea96506ea
46 changed files with 4401 additions and 4025 deletions

View File

@@ -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) {

View 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();

View 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 };

View 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 };

View 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),
};
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
}
}