diff --git a/services/git-integration/src/migrations/001_github_integration.sql b/services/git-integration/src/migrations/001_github_integration.sql index 475743e..ed99e51 100644 --- a/services/git-integration/src/migrations/001_github_integration.sql +++ b/services/git-integration/src/migrations/001_github_integration.sql @@ -19,17 +19,6 @@ CREATE TABLE IF NOT EXISTS github_repositories ( updated_at TIMESTAMP DEFAULT NOW() ); --- Create table for feature-codebase mappings -CREATE TABLE IF NOT EXISTS feature_codebase_mappings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - template_id UUID REFERENCES templates(id) ON DELETE CASCADE, - feature_id UUID REFERENCES template_features(id) ON DELETE CASCADE, - repository_id UUID REFERENCES github_repositories(id) ON DELETE CASCADE, - file_paths TEXT[], - code_snippets JSONB, - implementation_notes TEXT, - created_at TIMESTAMP DEFAULT NOW() -); -- Create indexes for better performance CREATE INDEX IF NOT EXISTS idx_github_repos_template_id ON github_repositories(template_id); diff --git a/services/git-integration/src/migrations/004_webhook_events.sql b/services/git-integration/src/migrations/004_webhook_events.sql deleted file mode 100644 index 3e71adc..0000000 --- a/services/git-integration/src/migrations/004_webhook_events.sql +++ /dev/null @@ -1,35 +0,0 @@ --- Create table if it does not exist (compatible with existing schemas) -CREATE TABLE IF NOT EXISTS webhook_events ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - event_type VARCHAR(100) NOT NULL, - processing_status VARCHAR(50) DEFAULT 'pending', - created_at TIMESTAMP DEFAULT NOW() -); - --- Bring table to desired schema (idempotent) -ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS action VARCHAR(100); -ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS repository_full_name VARCHAR(255); -ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS delivery_id VARCHAR(100); -ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS metadata JSONB; -ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS received_at TIMESTAMP DEFAULT NOW(); -ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS processed_at TIMESTAMP; -ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS error_message TEXT; -ALTER TABLE webhook_events ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW(); - --- Create indexes (safe if columns now exist) -CREATE INDEX IF NOT EXISTS idx_webhook_events_event_type ON webhook_events(event_type); -CREATE INDEX IF NOT EXISTS idx_webhook_events_repository ON webhook_events(repository_full_name); -CREATE INDEX IF NOT EXISTS idx_webhook_events_received_at ON webhook_events(received_at); -CREATE INDEX IF NOT EXISTS idx_webhook_events_delivery_id ON webhook_events(delivery_id); -CREATE INDEX IF NOT EXISTS idx_webhook_events_status ON webhook_events(processing_status); - --- Add trigger to update timestamp -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_trigger WHERE tgname = 'update_webhook_events_updated_at' - ) THEN - CREATE TRIGGER update_webhook_events_updated_at BEFORE UPDATE ON webhook_events - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - END IF; -END $$; diff --git a/services/git-integration/src/routes/github-integration.routes.js b/services/git-integration/src/routes/github-integration.routes.js index fc8b663..b3bc92d 100644 --- a/services/git-integration/src/routes/github-integration.routes.js +++ b/services/git-integration/src/routes/github-integration.routes.js @@ -29,41 +29,204 @@ router.post('/attach-repository', async (req, res) => { // Parse GitHub URL const { owner, repo, branch } = githubService.parseGitHubUrl(repository_url); - // Check repository access with user-specific tokens - const accessCheck = await githubService.checkRepositoryAccessWithUser(owner, repo, userId); - - if (!accessCheck.hasAccess) { - if (accessCheck.requiresAuth || accessCheck.authError) { - // Generate an auth URL that encodes the current user and returns absolute via gateway - const state = Math.random().toString(36).substring(7); + // First, try to check if this is a public repository without authentication + let isPublicRepo = false; + let repositoryData = null; + + try { + // Try to access the repository without authentication first (for public repos) + const unauthenticatedOctokit = new (require('@octokit/rest')).Octokit({ + userAgent: 'CodeNuk-GitIntegration/1.0.0', + }); + + const { data: repoInfo } = await unauthenticatedOctokit.repos.get({ owner, repo }); + isPublicRepo = !repoInfo.private; + repositoryData = { + full_name: repoInfo.full_name, + description: repoInfo.description, + language: repoInfo.language, + visibility: repoInfo.private ? 'private' : 'public', + stargazers_count: repoInfo.stargazers_count, + forks_count: repoInfo.forks_count, + default_branch: repoInfo.default_branch, + size: repoInfo.size, + updated_at: repoInfo.updated_at + }; + + console.log(`✅ Repository ${owner}/${repo} is ${isPublicRepo ? 'public' : 'private'}`); + } catch (error) { + // IMPORTANT: GitHub returns 404 for private repos when unauthenticated. + // Do NOT immediately return 404 here; instead continue to check auth and treat as potentially private. + if (error.status && error.status !== 404) { + // For non-404 errors (e.g., rate-limit, network), surface a meaningful message + console.warn(`Unauthenticated access failed with status ${error.status}: ${error.message}`); + } + + // If we can't access it without auth (including 404), it's likely private - check if user has auth + console.log(`❌ Cannot access ${owner}/${repo} without authentication (status=${error.status || 'unknown'}), checking user auth...`); + + // Check if user has GitHub authentication + let hasAuth = false; + try { + const authStatus = await oauthService.getAuthStatus(); + hasAuth = authStatus.connected; + console.log(`🔐 User authentication status: ${hasAuth ? 'Connected' : 'Not connected'}`); + } catch (authError) { + console.log(`❌ Error checking auth status: ${authError.message}`); + hasAuth = false; + } + + // If user is not authenticated, first optimistically try to attach as PUBLIC via git clone. + // If cloning fails (e.g., due to private permissions), then prompt OAuth. + if (!hasAuth) { + try { + // Minimal metadata assuming public repo + repositoryData = { + full_name: `${owner}/${repo}`, + description: null, + language: null, + visibility: 'public', + stargazers_count: 0, + forks_count: 0, + default_branch: branch || branch_name || 'main', + size: 0, + updated_at: new Date().toISOString() + }; + isPublicRepo = true; + + // Determine branch fallback + let actualBranchLocal = repositoryData.default_branch || 'main'; + if (branch_name) actualBranchLocal = branch_name; + + // Store DB record before syncing (sync_status=syncing) + const insertQueryPublic = ` + INSERT INTO github_repositories ( + repository_url, repository_name, owner_name, + branch_name, is_public, metadata, codebase_analysis, sync_status, + requires_auth, user_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING * + `; + const insertValuesPublic = [ + repository_url, + repo, + owner, + actualBranchLocal, + true, + JSON.stringify(repositoryData), + JSON.stringify({ branch: actualBranchLocal, total_files: 0, total_size: 0 }), + 'syncing', + false, + userId || null + ]; + const insertResPublic = await database.query(insertQueryPublic, insertValuesPublic); + const repositoryRecordPublic = insertResPublic.rows[0]; + + // Try to sync via git without auth (attempt preferred branch, then alternate if needed) + let downloadResultPublic = await githubService.syncRepositoryWithFallback( + owner, repo, actualBranchLocal, repositoryRecordPublic.id, true + ); + if (!downloadResultPublic.success) { + const altBranch = actualBranchLocal === 'main' ? 'master' : 'main'; + console.warn(`First public sync attempt failed on '${actualBranchLocal}', retrying with alternate branch '${altBranch}'...`); + downloadResultPublic = await githubService.syncRepositoryWithFallback( + owner, repo, altBranch, repositoryRecordPublic.id, true + ); + if (downloadResultPublic.success) { + // Persist branch switch + await database.query('UPDATE github_repositories SET branch_name = $1, updated_at = NOW() WHERE id = $2', [altBranch, repositoryRecordPublic.id]); + actualBranchLocal = altBranch; + } + } + + const finalSyncStatusPublic = downloadResultPublic.success ? 'synced' : 'error'; + await database.query( + 'UPDATE github_repositories SET sync_status = $1, updated_at = NOW() WHERE id = $2', + [finalSyncStatusPublic, repositoryRecordPublic.id] + ); + + if (downloadResultPublic.success) { + const storageInfoPublic = await githubService.getRepositoryStorage(repositoryRecordPublic.id); + return res.status(201).json({ + success: true, + message: 'Repository attached and synced successfully (public, no auth)', + data: { + repository_id: repositoryRecordPublic.id, + repository_name: repositoryRecordPublic.repository_name, + owner_name: repositoryRecordPublic.owner_name, + branch_name: repositoryRecordPublic.branch_name, + is_public: true, + requires_auth: false, + sync_status: finalSyncStatusPublic, + metadata: repositoryData, + codebase_analysis: { branch: actualBranchLocal }, + storage_info: storageInfoPublic, + download_result: downloadResultPublic + } + }); + } + + // If we reach here, public clone failed: likely private → prompt OAuth now + console.warn('Public clone attempt failed; switching to OAuth requirement'); + } catch (probeErr) { + console.warn('Optimistic public attach failed:', probeErr.message); + } + + // Generate an auth URL that encodes the current user AND repo context so callback can auto-attach + const stateBase = Math.random().toString(36).substring(7); const userIdForAuth = userId || null; + const encodedRepoUrl = encodeURIComponent(repository_url); + const encodedBranchName = encodeURIComponent(branch_name || ''); + const state = `${stateBase}|uid=${userIdForAuth || ''}|repo=${encodedRepoUrl}|branch=${encodedBranchName}`; const rawAuthUrl = oauthService.getAuthUrl(state, userIdForAuth); - // Prefer returning a gateway URL so frontend doesn't need to know service ports const gatewayBase = process.env.API_GATEWAY_PUBLIC_URL || 'http://localhost:8000'; const serviceRelative = '/api/github/auth/github'; - // redirect=1 makes the endpoint issue a 302 directly to GitHub so UI doesn't land on JSON const serviceAuthUrl = `${gatewayBase}${serviceRelative}?redirect=1&state=${encodeURIComponent(state)}${userIdForAuth ? `&user_id=${encodeURIComponent(userIdForAuth)}` : ''}`; return res.status(401).json({ success: false, - message: accessCheck.error || 'GitHub authentication required for this repository', + message: 'GitHub authentication required or repository is private', requires_auth: true, - // Return both, frontend can pick the gateway URL auth_url: serviceAuthUrl, service_auth_url: rawAuthUrl, - auth_error: accessCheck.authError || false + auth_error: false + }); + } + + // User is authenticated, try to access the repository with auth + try { + const octokit = await githubService.getAuthenticatedOctokit(); + const { data: repoInfo } = await octokit.repos.get({ owner, repo }); + + isPublicRepo = false; // This is a private repo + repositoryData = { + full_name: repoInfo.full_name, + description: repoInfo.description, + language: repoInfo.language, + visibility: 'private', + stargazers_count: repoInfo.stargazers_count, + forks_count: repoInfo.forks_count, + default_branch: repoInfo.default_branch, + size: repoInfo.size, + updated_at: repoInfo.updated_at + }; + + console.log(`✅ Private repository ${owner}/${repo} accessed with authentication`); + } catch (authError) { + console.log(`❌ Cannot access ${owner}/${repo} even with authentication: ${authError.message}`); + + return res.status(403).json({ + success: false, + message: 'Repository not accessible - you may not have permission to access this repository' }); } - - return res.status(404).json({ - success: false, - message: accessCheck.error || 'Repository not accessible' - }); } - // Get repository information from GitHub - const repositoryData = await githubService.fetchRepositoryMetadata(owner, repo); + // If we don't have repository data yet (private repo), fetch it with authentication + if (!repositoryData) { + repositoryData = await githubService.fetchRepositoryMetadata(owner, repo); + } // Use the actual default branch from repository metadata if the requested branch doesn't exist let actualBranch = branch || branch_name || repositoryData.default_branch || 'main'; @@ -71,7 +234,16 @@ router.post('/attach-repository', async (req, res) => { // Validate that the requested branch exists, fallback to default if not try { if (branch || branch_name) { - const octokit = await githubService.getAuthenticatedOctokit(); + // Use authenticated octokit for private repos, unauthenticated for public + let octokit; + if (isPublicRepo) { + octokit = new (require('@octokit/rest')).Octokit({ + userAgent: 'CodeNuk-GitIntegration/1.0.0', + }); + } else { + octokit = await githubService.getAuthenticatedOctokit(); + } + await octokit.git.getRef({ owner, repo, @@ -88,7 +260,7 @@ router.post('/attach-repository', async (req, res) => { } // Analyze the codebase - const codebaseAnalysis = await githubService.analyzeCodebase(owner, repo, actualBranch); + const codebaseAnalysis = await githubService.analyzeCodebase(owner, repo, actualBranch, isPublicRepo); // Store everything in PostgreSQL (without template_id) const insertQuery = ` @@ -105,31 +277,42 @@ router.post('/attach-repository', async (req, res) => { repo, owner, actualBranch, - repositoryData.visibility === 'public', + isPublicRepo, JSON.stringify(repositoryData), JSON.stringify(codebaseAnalysis), - 'synced', - accessCheck.requiresAuth, + 'syncing', // Start with syncing status + !isPublicRepo, // requires_auth is true for private repos userId || null ]; const insertResult = await database.query(insertQuery, insertValues); const repositoryRecord = insertResult.rows[0]; - // Attempt to auto-create webhook on the attached repository using OAuth token - const publicBaseUrl = process.env.PUBLIC_BASE_URL || null; // e.g., your ngrok URL https://xxx.ngrok-free.app - const callbackUrl = publicBaseUrl ? `${publicBaseUrl}/api/github/webhook` : null; - const webhookResult = await githubService.ensureRepositoryWebhook(owner, repo, callbackUrl); + // Attempt to auto-create webhook on the attached repository using OAuth token (only for authenticated repos) + let webhookResult = null; + if (!isPublicRepo) { + const publicBaseUrl = process.env.PUBLIC_BASE_URL || null; // e.g., your ngrok URL https://xxx.ngrok-free.app + const callbackUrl = publicBaseUrl ? `${publicBaseUrl}/api/github/webhook` : null; + webhookResult = await githubService.ensureRepositoryWebhook(owner, repo, callbackUrl); + } // Sync with fallback: try git first, then API - console.log('Syncing repository (git first, API fallback)...'); + console.log(`Syncing ${isPublicRepo ? 'public' : 'private'} repository (git first, API fallback)...`); const downloadResult = await githubService.syncRepositoryWithFallback( - owner, repo, actualBranch, repositoryRecord.id + owner, repo, actualBranch, repositoryRecord.id, isPublicRepo + ); + + // Update sync status based on download result + const finalSyncStatus = downloadResult.success ? 'synced' : 'error'; + await database.query( + 'UPDATE github_repositories SET sync_status = $1, updated_at = NOW() WHERE id = $2', + [finalSyncStatus, repositoryRecord.id] ); if (!downloadResult.success) { - // If download failed, still return the repository record but mark the storage issue console.warn('Repository download failed:', downloadResult.error); + } else { + console.log(`✅ Repository ${owner}/${repo} synced successfully using ${downloadResult.method} method`); } // Get storage information @@ -137,18 +320,25 @@ router.post('/attach-repository', async (req, res) => { res.status(201).json({ success: true, - message: 'Repository attached successfully', + message: `Repository attached and ${downloadResult.success ? 'synced' : 'partially synced'} successfully`, data: { repository_id: repositoryRecord.id, repository_name: repositoryRecord.repository_name, owner_name: repositoryRecord.owner_name, branch_name: repositoryRecord.branch_name, - is_public: repositoryRecord.is_public, - requires_auth: repositoryRecord.requires_auth, + is_public: isPublicRepo, + requires_auth: !isPublicRepo, + sync_status: finalSyncStatus, metadata: repositoryData, codebase_analysis: codebaseAnalysis, storage_info: storageInfo, - download_result: downloadResult + download_result: downloadResult, + webhook_result: webhookResult, + authentication_info: { + is_public: isPublicRepo, + authenticated: !isPublicRepo, + github_username: null + } } }); diff --git a/services/git-integration/src/routes/github-oauth.js b/services/git-integration/src/routes/github-oauth.js index 313535b..5840fcd 100644 --- a/services/git-integration/src/routes/github-oauth.js +++ b/services/git-integration/src/routes/github-oauth.js @@ -8,7 +8,10 @@ const oauthService = new GitHubOAuthService(); // Initiate GitHub OAuth flow (supports optional user_id). If redirect=1, do 302 to GitHub. router.get('/auth/github', async (req, res) => { try { - const state = Math.random().toString(36).substring(7); + // If caller provided a state (e.g., containing repo context), use it; else generate one + const state = (typeof req.query.state === 'string' && req.query.state.length > 0) + ? req.query.state + : Math.random().toString(36).substring(7); const userId = req.query.user_id || (req.body && req.body.user_id) || @@ -88,10 +91,67 @@ router.get('/auth/github/callback', async (req, res) => { console.log('[GitHub OAuth] callback about to store token for user_id =', user_id || null); const tokenRecord = await oauthService.storeToken(accessToken, githubUser, user_id || null); + // If the OAuth state contains repo context from the attach flow, auto-attach and start sync + // State format from attach: `${random}|uid=${userId}|repo=${encodeURIComponent(url)}|branch=${encodeURIComponent(branch)}` + let autoAttach = null; + try { + if (typeof state === 'string' && state.includes('|repo=')) { + const parts = Object.fromEntries( + state.split('|').slice(1).map(kv => { + const [k, ...rest] = kv.split('='); + return [k, rest.join('=')]; + }) + ); + const repoUrl = parts.repo ? decodeURIComponent(parts.repo) : null; + const branchName = parts.branch ? decodeURIComponent(parts.branch) : null; + if (repoUrl) { + console.log('[GitHub OAuth] Auto-attach requested for repo:', repoUrl, 'branch:', branchName || '(default)'); + const GitHubIntegrationService = require('../services/github-integration.service'); + const database = require('../config/database'); + const githubService = new GitHubIntegrationService(); + const { owner, repo, branch } = githubService.parseGitHubUrl(repoUrl); + // Get metadata using authenticated Octokit now that token is stored + const repositoryData = await githubService.fetchRepositoryMetadata(owner, repo); + let actualBranch = branchName || branch || repositoryData.default_branch || 'main'; + // Attempt analysis and sync with fallback + const codebaseAnalysis = await githubService.analyzeCodebase(owner, repo, actualBranch, false); + const insertQuery = ` + INSERT INTO github_repositories ( + repository_url, repository_name, owner_name, + branch_name, is_public, metadata, codebase_analysis, sync_status, + requires_auth, user_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING * + `; + const insertValues = [ + repoUrl, + repo, + owner, + actualBranch, + repositoryData.visibility !== 'private', + JSON.stringify(repositoryData), + JSON.stringify(codebaseAnalysis), + 'syncing', + repositoryData.visibility === 'private', + user_id || null, + ]; + const insertResult = await database.query(insertQuery, insertValues); + const repositoryRecord = insertResult.rows[0]; + // Try to sync + const downloadResult = await githubService.syncRepositoryWithFallback(owner, repo, actualBranch, repositoryRecord.id, repositoryData.visibility !== 'private'); + const finalSyncStatus = downloadResult.success ? 'synced' : 'error'; + await database.query('UPDATE github_repositories SET sync_status = $1, updated_at = NOW() WHERE id = $2', [finalSyncStatus, repositoryRecord.id]); + autoAttach = { repository_id: repositoryRecord.id, sync_status: finalSyncStatus }; + } + } + } catch (autoErr) { + console.warn('[GitHub OAuth] Auto-attach failed:', autoErr.message); + } + // Redirect back to frontend if configured const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; try { - const redirectUrl = `${frontendUrl}/project-builder?github_connected=1&user=${encodeURIComponent(githubUser.login)}`; + const redirectUrl = `${frontendUrl}/project-builder?github_connected=1&user=${encodeURIComponent(githubUser.login)}${autoAttach ? `&repo_attached=1&repository_id=${encodeURIComponent(autoAttach.repository_id)}&sync_status=${encodeURIComponent(autoAttach.sync_status)}` : ''}`; return res.redirect(302, redirectUrl); } catch (e) { // Fallback to JSON if redirect fails @@ -101,7 +161,8 @@ router.get('/auth/github/callback', async (req, res) => { data: { github_username: githubUser.login, github_user_id: githubUser.id, - connected_at: tokenRecord.created_at + connected_at: tokenRecord.created_at, + auto_attach: autoAttach || null } }); } diff --git a/services/git-integration/src/services/github-integration.service.js b/services/git-integration/src/services/github-integration.service.js index 63e3845..31a57ea 100644 --- a/services/git-integration/src/services/github-integration.service.js +++ b/services/git-integration/src/services/github-integration.service.js @@ -168,36 +168,71 @@ class GitHubIntegrationService { }; } - // No token found that can access this repository - return { - exists: null, - isPrivate: null, - hasAccess: false, - requiresAuth: true, - error: 'Repository not found or requires authentication', - authError: false - }; - - } catch (error) { - if (error.status === 404) { + // No token found - try unauthenticated access first to check if it's public + try { + const unauthenticatedOctokit = new Octokit({ + userAgent: 'CodeNuk-GitIntegration/1.0.0', + }); + const { data } = await unauthenticatedOctokit.repos.get({ owner, repo }); + + // Repository exists and is public return { - exists: false, + exists: true, + isPrivate: false, + hasAccess: true, + requiresAuth: false, + github_username: null, + token_id: null + }; + } catch (unauthenticatedError) { + if (unauthenticatedError.status === 404) { + // Repository truly doesn't exist + return { + exists: false, + isPrivate: null, + hasAccess: false, + requiresAuth: false, + error: 'Repository not found' + }; + } else if (unauthenticatedError.status === 401 || unauthenticatedError.status === 403) { + // Repository exists but requires authentication (private) - generate auth URL + const authUrl = await this.oauthService.generateAuthUrl(userId); + return { + exists: true, + isPrivate: true, + hasAccess: false, + requiresAuth: true, + error: 'Private repository requires authentication', + authError: false, + auth_url: authUrl + }; + } + + // Other error - treat as private repository requiring auth - generate auth URL + const authUrl = await this.oauthService.generateAuthUrl(userId); + return { + exists: null, isPrivate: null, hasAccess: false, requiresAuth: true, - error: 'Repository not found or requires authentication' + error: 'Repository requires authentication', + authError: false, + auth_url: authUrl }; } - // Handle authentication errors + } catch (error) { + // Handle authentication errors - generate auth URL if (error.status === 401 || error.message.includes('token has expired') || error.message.includes('authenticate with GitHub')) { + const authUrl = await this.oauthService.generateAuthUrl(userId); return { exists: null, isPrivate: null, hasAccess: false, requiresAuth: true, error: 'GitHub authentication required or token expired', - authError: true + authError: true, + auth_url: authUrl }; } @@ -249,9 +284,19 @@ class GitHubIntegrationService { } // Analyze codebase structure - async analyzeCodebase(owner, repo, branch) { + async analyzeCodebase(owner, repo, branch, isPublicRepo = false) { try { - const octokit = await this.getAuthenticatedOctokit(); + // Use appropriate octokit instance based on repository type + let octokit; + if (isPublicRepo) { + // For public repos, use unauthenticated octokit + octokit = new Octokit({ + userAgent: 'CodeNuk-GitIntegration/1.0.0', + }); + } else { + // For private repos, use authenticated octokit + octokit = await this.getAuthenticatedOctokit(); + } // Get the commit SHA for the branch const { data: ref } = await octokit.git.getRef({ @@ -394,7 +439,7 @@ class GitHubIntegrationService { } // Git-based: clone or update local repo and re-index into DB - async syncRepositoryWithGit(owner, repo, branch, repositoryId) { + async syncRepositoryWithGit(owner, repo, branch, repositoryId, isPublicRepo = false) { const database = require('../config/database'); const localPath = this.gitRepoService.getLocalRepoPath(owner, repo, branch); let storageRecord = null; @@ -408,24 +453,56 @@ class GitHubIntegrationService { localPath ); - // Clone if missing (prefer authenticated HTTPS with OAuth token), otherwise fetch & fast-forward + // Clone if missing (prefer authenticated HTTPS with OAuth token for private repos, public for public repos) let repoPath = null; - try { - const tokenRecord = await this.oauthService.getToken(); - if (tokenRecord?.access_token) { - repoPath = await this.gitRepoService.cloneIfMissingWithAuth( - owner, - repo, - branch, - 'github.com', - tokenRecord.access_token, - 'oauth2' - ); + + if (isPublicRepo) { + // For public repos, try unauthenticated clone first + try { + repoPath = await this.gitRepoService.cloneIfMissing(owner, repo, branch); + } catch (error) { + console.warn(`Failed to clone public repo without auth: ${error.message}`); + // Fallback to authenticated clone if available + try { + const tokenRecord = await this.oauthService.getToken(); + if (tokenRecord?.access_token) { + repoPath = await this.gitRepoService.cloneIfMissingWithAuth( + owner, + repo, + branch, + 'github.com', + tokenRecord.access_token, + 'oauth2' + ); + } + } catch (_) {} + } + } else { + // For private repos, try authenticated clone first + try { + const tokenRecord = await this.oauthService.getToken(); + if (tokenRecord?.access_token) { + repoPath = await this.gitRepoService.cloneIfMissingWithAuth( + owner, + repo, + branch, + 'github.com', + tokenRecord.access_token, + 'oauth2' + ); + } + } catch (_) {} + + // Fallback to unauthenticated clone (will likely fail for private repos) + if (!repoPath) { + repoPath = await this.gitRepoService.cloneIfMissing(owner, repo, branch); } - } catch (_) {} - if (!repoPath) { - repoPath = await this.gitRepoService.cloneIfMissing(owner, repo, branch); } + + if (!repoPath) { + throw new Error('Failed to clone repository'); + } + const beforeSha = await this.gitRepoService.getHeadSha(repoPath); const { afterSha } = await this.gitRepoService.fetchAndFastForward(repoPath, branch); @@ -563,15 +640,15 @@ class GitHubIntegrationService { } // Try git-based sync first, fall back to GitHub API download on failure - async syncRepositoryWithFallback(owner, repo, branch, repositoryId) { + async syncRepositoryWithFallback(owner, repo, branch, repositoryId, isPublicRepo = false) { // First attempt: full git clone/fetch and index - const gitResult = await this.syncRepositoryWithGit(owner, repo, branch, repositoryId); + const gitResult = await this.syncRepositoryWithGit(owner, repo, branch, repositoryId, isPublicRepo); if (gitResult && gitResult.success) { return { method: 'git', ...gitResult }; } // Fallback: API-based download and storage - const apiResult = await this.downloadRepositoryWithStorage(owner, repo, branch, repositoryId); + const apiResult = await this.downloadRepositoryWithStorage(owner, repo, branch, repositoryId, isPublicRepo); if (apiResult && apiResult.success) { return { method: 'api', ...apiResult, git_error: gitResult?.error }; } @@ -580,7 +657,7 @@ class GitHubIntegrationService { } // Download repository files locally and store in database - async downloadRepositoryWithStorage(owner, repo, branch, repositoryId) { + async downloadRepositoryWithStorage(owner, repo, branch, repositoryId, isPublicRepo = false) { const targetDir = path.join( process.env.ATTACHED_REPOS_DIR, `${owner}__${repo}__${branch}` @@ -600,7 +677,17 @@ class GitHubIntegrationService { targetDir ); - const octokit = await this.getAuthenticatedOctokit(); + // Use appropriate octokit instance based on repository type + let octokit; + if (isPublicRepo) { + // For public repos, use unauthenticated octokit + octokit = new Octokit({ + userAgent: 'CodeNuk-GitIntegration/1.0.0', + }); + } else { + // For private repos, use authenticated octokit + octokit = await this.getAuthenticatedOctokit(); + } // Get the commit SHA for the branch const { data: ref } = await octokit.git.getRef({ diff --git a/services/git-integration/src/services/github-oauth.js b/services/git-integration/src/services/github-oauth.js index b2b0ff0..c1cbb01 100644 --- a/services/git-integration/src/services/github-oauth.js +++ b/services/git-integration/src/services/github-oauth.js @@ -40,6 +40,12 @@ class GitHubOAuthService { return `https://github.com/login/oauth/authorize?${params.toString()}`; } + // Generate auth URL for a specific user (wrapper method) + async generateAuthUrl(userId) { + const state = Math.random().toString(36).substring(7); + return this.getAuthUrl(state, userId); + } + // Exchange authorization code for access token async exchangeCodeForToken(code) { const response = await fetch('https://github.com/login/oauth/access_token', {