From e251a4e317ab0c22683a146c258363c42b7c000c Mon Sep 17 00:00:00 2001 From: Samuel Chau Date: Sun, 30 Mar 2025 20:42:31 +1030 Subject: [PATCH] fix: clone permissions for private repos (#190) - reverted back to Flask development server for development - improved / suppressed git logging - fixed cloning for private repos by including PAT in clone job - add 'private repo' badge that replaces repository stats for private repos --- backend/Dockerfile | 2 +- backend/app/git/auth/authenticate.py | 14 ++- backend/app/git/repo/clone.py | 26 +++- backend/app/init.py | 5 + .../settings/git/repo/ActiveRepo.jsx | 14 +++ .../components/settings/git/repo/LinkRepo.jsx | 53 +++++++- .../settings/git/repo/RepoContainer.jsx | 114 +++++++++++++----- 7 files changed, 189 insertions(+), 39 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 5deb4d3..6e6b528 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,4 +4,4 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . # Use gunicorn with 10-minute timeout -CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--timeout", "600", "app.main:create_app()"] \ No newline at end of file +CMD ["python", "-m", "app.main"] \ No newline at end of file diff --git a/backend/app/git/auth/authenticate.py b/backend/app/git/auth/authenticate.py index c26484b..37bf28a 100644 --- a/backend/app/git/auth/authenticate.py +++ b/backend/app/git/auth/authenticate.py @@ -15,12 +15,24 @@ class GitHubAuth: def get_authenticated_url(https_url): """ Convert an HTTPS URL to include authentication via PAT. + Ensures the token is not duplicated in the URL. """ token = os.getenv("PROFILARR_PAT") if not token: raise ValueError( "PROFILARR_PAT is not set in environment variables") - + + # Check if the URL already contains authentication + if "@" in https_url: + # Already has some form of authentication, remove it to add our token + # This handles URLs that might have a token already + protocol_part, rest = https_url.split("://", 1) + if "@" in rest: + # Remove any existing authentication + _, server_part = rest.split("@", 1) + https_url = f"{protocol_part}://{server_part}" + + # Now add our token authenticated_url = https_url.replace("https://", f"https://{token}@") return authenticated_url diff --git a/backend/app/git/repo/clone.py b/backend/app/git/repo/clone.py index 329488d..801f82a 100644 --- a/backend/app/git/repo/clone.py +++ b/backend/app/git/repo/clone.py @@ -6,6 +6,7 @@ import logging import yaml from git.exc import GitCommandError import git +from ..auth.authenticate import GitHubAuth logger = logging.getLogger(__name__) @@ -19,16 +20,35 @@ def clone_repository(repo_url, repo_path): # Initial clone attempt logger.info(f"Starting clone operation for {repo_url}") try: + # First try without authentication (for public repos) repo = git.Repo.clone_from(repo_url, temp_dir) logger.info("Repository clone successful") except GitCommandError as e: - if "remote: Repository not found" in str(e): + error_str = str(e) + # If authentication error, try with token + if "could not read Username" in error_str or "Authentication failed" in error_str: + logger.info("Initial clone failed due to authentication. Trying with token...") + try: + # Verify token availability + if not GitHubAuth.verify_token(): + logger.error("Private repository requires GitHub authentication. Please configure PAT.") + return False, "This appears to be a private repository. Please configure PROFILARR_PAT environment variable." + + # Get authenticated URL for private repositories + authenticated_url = GitHubAuth.get_authenticated_url(repo_url) + repo = git.Repo.clone_from(authenticated_url, temp_dir) + logger.info("Repository clone with authentication successful") + except GitCommandError as auth_e: + logger.error(f"Clone with authentication failed: {str(auth_e)}") + return False, f"Failed to clone repository: {str(auth_e)}" + # If repository not found, create new one + elif "remote: Repository not found" in error_str: logger.info("Creating new repository - remote not found") repo = git.Repo.init(temp_dir) repo.create_remote('origin', repo_url) else: - logger.error(f"Clone failed: {str(e)}") - return False, f"Failed to clone repository: {str(e)}" + logger.error(f"Clone failed: {error_str}") + return False, f"Failed to clone repository: {error_str}" # Check if repo is empty try: diff --git a/backend/app/init.py b/backend/app/init.py index bbdec4a..84e0f2f 100644 --- a/backend/app/init.py +++ b/backend/app/init.py @@ -85,6 +85,11 @@ def setup_logging(): 'level': 'INFO', 'handlers': ['console', 'file'], 'propagate': False + }, + 'git': { + 'level': 'ERROR', + 'handlers': ['console', 'file'], + 'propagate': False } } } diff --git a/frontend/src/components/settings/git/repo/ActiveRepo.jsx b/frontend/src/components/settings/git/repo/ActiveRepo.jsx index ca32c19..ed596ac 100644 --- a/frontend/src/components/settings/git/repo/ActiveRepo.jsx +++ b/frontend/src/components/settings/git/repo/ActiveRepo.jsx @@ -37,6 +37,20 @@ const RepoStats = ({repoStats}) => { if (!repoStats) { return ; } + + // For private repositories or when stats can't be loaded + if (repoStats.isPrivate) { + return ( +
+ + + + + Private + +
+ ); + } return ( <> diff --git a/frontend/src/components/settings/git/repo/LinkRepo.jsx b/frontend/src/components/settings/git/repo/LinkRepo.jsx index d91dfe5..715972b 100644 --- a/frontend/src/components/settings/git/repo/LinkRepo.jsx +++ b/frontend/src/components/settings/git/repo/LinkRepo.jsx @@ -21,9 +21,56 @@ const LinkRepo = ({isOpen, onClose, onSubmit}) => { ); onSubmit(); } catch (error) { - Alert.error( - 'An unexpected error occurred while linking the repository.' - ); + // Check for specific error cases + if (error.response) { + const { status, data } = error.response; + + if (data && data.error) { + // Authentication errors for private repos + if (data.error.includes("could not read Username") || + data.error.includes("Authentication failed") || + data.error.includes("authentication") || + data.error.includes("PROFILARR_PAT")) { + Alert.error( + 'Authentication failed. Please ensure you have configured a valid GitHub Personal Access Token in your .env file (PROFILARR_PAT).' + ); + } + // Repository not found + else if (data.error.includes("not found") || status === 404) { + Alert.error( + 'Repository not found. Please check the URL and ensure you have access to this repository.' + ); + } + // Permission issues (general 403) + else if (data.error.includes("permission") || + data.error.includes("error: 403") || + status === 403) { + Alert.error( + 'Permission denied. Your GitHub token may not have sufficient permissions to access this repository. Ensure it has "Contents: Read & write" permission.' + ); + } + // Write access issues - specifically check for this common error + else if (data.error.includes("remote: Write access to repository not granted")) { + Alert.error( + 'Your GitHub token does not have write access to this repository. Please update your token with the "Contents: Read & write" permission.' + ); + } + // Any other error - use a generic message rather than showing the raw error + else { + Alert.error( + 'Failed to link repository. Please check the URL and your GitHub token permissions.' + ); + } + } else { + // HTTP error without specific message + Alert.error(`Failed to link repository (Error ${status})`); + } + } else { + // Network or other errors + Alert.error( + 'Failed to connect to the server. Please check your network connection.' + ); + } console.error('Error linking repository:', error); } finally { setLoading(false); diff --git a/frontend/src/components/settings/git/repo/RepoContainer.jsx b/frontend/src/components/settings/git/repo/RepoContainer.jsx index 9a6d07d..2e9ad28 100644 --- a/frontend/src/components/settings/git/repo/RepoContainer.jsx +++ b/frontend/src/components/settings/git/repo/RepoContainer.jsx @@ -84,42 +84,94 @@ const RepoContainer = ({settings, setSettings, fetchGitStatus, status}) => { } try { - const [avatarResponse, repoResponse] = await Promise.all([ - !isAvatarValid && - fetch(`https://api.github.com/users/${owner}`), - !isStatsValid && - fetch(`https://api.github.com/repos/${owner}/${repo}`) - ]); - - if (!isAvatarValid && avatarResponse.ok) { - const userData = await avatarResponse.json(); - localStorage.setItem(avatarCacheKey, userData.avatar_url); - localStorage.setItem( - `${avatarCacheKey}-timestamp`, - Date.now().toString() - ); - setAvatarUrl(userData.avatar_url); + // Try to fetch the avatar if needed + if (!isAvatarValid) { + try { + const avatarResponse = await fetch(`https://api.github.com/users/${owner}`); + + if (avatarResponse.ok) { + const userData = await avatarResponse.json(); + localStorage.setItem(avatarCacheKey, userData.avatar_url); + localStorage.setItem( + `${avatarCacheKey}-timestamp`, + Date.now().toString() + ); + setAvatarUrl(userData.avatar_url); + } else if (avatarResponse.status === 404) { + // User not found - use first letter avatar + setAvatarUrl(null); + } + } catch (avatarError) { + console.warn('Could not fetch avatar:', avatarError); + // Keep using cached avatar if available + if (cachedAvatar) setAvatarUrl(cachedAvatar); + } } - - if (!isStatsValid && repoResponse.ok) { - const repoData = await repoResponse.json(); - const stats = { - stars: repoData.stargazers_count, - forks: repoData.forks_count, - issues: repoData.open_issues_count - }; - localStorage.setItem(statsCacheKey, JSON.stringify(stats)); - localStorage.setItem( - `${statsCacheKey}-timestamp`, - Date.now().toString() - ); - setRepoStats(stats); + + // Try to fetch repo stats if needed + if (!isStatsValid) { + try { + const repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`); + + if (repoResponse.ok) { + const repoData = await repoResponse.json(); + const stats = { + stars: repoData.stargazers_count, + forks: repoData.forks_count, + issues: repoData.open_issues_count + }; + localStorage.setItem(statsCacheKey, JSON.stringify(stats)); + localStorage.setItem( + `${statsCacheKey}-timestamp`, + Date.now().toString() + ); + setRepoStats(stats); + } else if (repoResponse.status === 404 || repoResponse.status === 403) { + // Repository not found or private - set empty stats + const privateRepoStats = { + stars: '-', + forks: '-', + issues: '-', + isPrivate: true + }; + setRepoStats(privateRepoStats); + localStorage.setItem(statsCacheKey, JSON.stringify(privateRepoStats)); + localStorage.setItem( + `${statsCacheKey}-timestamp`, + Date.now().toString() + ); + } + } catch (statsError) { + console.warn('Could not fetch repo stats:', statsError); + // Use cached stats or create private repo placeholder + if (cachedStats) { + setRepoStats(JSON.parse(cachedStats)); + } else { + const privateRepoStats = { + stars: '-', + forks: '-', + issues: '-', + isPrivate: true + }; + setRepoStats(privateRepoStats); + } + } } } catch (error) { - console.error('Error fetching GitHub data:', error); + console.error('Error in fetchAvatarAndStats:', error); + // Use cached values if available if (cachedAvatar && !isAvatarValid) setAvatarUrl(cachedAvatar); - if (cachedStats && !isStatsValid) + if (cachedStats && !isStatsValid) { setRepoStats(JSON.parse(cachedStats)); + } else { + // Provide default stats for private repos + setRepoStats({ + stars: '-', + forks: '-', + issues: '-', + isPrivate: true + }); + } } };