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

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