#!/usr/bin/env -S uv --quiet run --script # /// script # requires-python = ">=3.12" # dependencies = [ # "PyGithub>=2.5.0", # ] # /// import sys from pathlib import Path import argparse import logging import random import asyncio from asyncio import Semaphore from typing import Optional from github import Github, GithubException logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) EXCLUDED_REPOS = {} class GitHubRepoSync: def __init__(self, token: Optional[str], org_name: str, base_path: Optional[str] = None, max_concurrent: int = 10): """Initialize the GitHub repository synchronization tool. Args: token: GitHub personal access token (if None, unauthenticated & heavily rate limited) org_name: Name of the GitHub organization base_path: Base path for cloning repositories max_concurrent: Maximum number of concurrent repository operations """ if not token: logger.warning("No token provided; unauthenticated requests are rate limited (60/hour). Use --token for higher throughput.") self.token = token self.org_name = org_name self.base_path = Path(base_path) if base_path else Path.cwd() self.semaphore = Semaphore(max_concurrent) self.errors: list[str] = [] # Initialize PyGithub client self.gh = Github(login_or_token=token, per_page=100) def get_repositories(self): """Fetch all repositories for the organization via PyGithub. Returns a list of Repository objects filtered by EXCLUDED_REPOS. """ try: org = self.gh.get_organization(self.org_name) repos = [] for repo in org.get_repos(): # PyGithub handles pagination internally if repo.name in EXCLUDED_REPOS: continue repos.append(repo) return repos except GithubException as ge: logger.error(f"GitHub API error: {ge.data if hasattr(ge, 'data') else ge}") raise except Exception as e: logger.error(f"Unexpected error fetching repositories: {e}") raise async def sync_repository(self, repo): """Sync a single repository - either clone it or pull latest changes.""" async with self.semaphore: repo_name = repo.name repo_path = self.base_path / repo_name ssh_url = repo.ssh_url if repo_path.exists(): logger.info(f"Pulling latest changes for {repo_name}") try: process = await asyncio.create_subprocess_exec( 'git', 'pull', cwd=repo_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await process.communicate() if process.returncode == 0: logger.info(f"Successfully updated {repo_name}") else: error_msg = f"Failed to pull {repo_name}: {stderr.decode()}" logger.error(error_msg) self.errors.append(error_msg) except Exception as e: error_msg = f"Failed to pull {repo_name}: {str(e)}" logger.error(error_msg) self.errors.append(error_msg) else: logger.info(f"Cloning {repo_name}") try: process = await asyncio.create_subprocess_exec( 'git', 'clone', ssh_url, cwd=self.base_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout, stderr = await process.communicate() if process.returncode == 0: logger.info(f"Successfully cloned {repo_name}") else: error_msg = f"Failed to clone {repo_name}: {stderr.decode()}" logger.error(error_msg) self.errors.append(error_msg) except Exception as e: error_msg = f"Failed to clone {repo_name}: {str(e)}" logger.error(error_msg) self.errors.append(error_msg) async def sync_all_repositories(self): """Synchronize all repositories in the organization.""" try: repos = self.get_repositories() logger.info(f"Found {len(repos)} repositories in {self.org_name}") random.shuffle(repos) tasks = [self.sync_repository(repo) for repo in repos] await asyncio.gather(*tasks) if self.errors: logger.error("\nSummary of all errors:") for error in self.errors: logger.error(error) logger.info("Repository synchronization completed") except Exception as e: logger.error(f"Failed to fetch repositories: {e}") sys.exit(1) async def async_main(): parser = argparse.ArgumentParser(description='Sync GitHub organization repositories') parser.add_argument('--token', help='GitHub personal access token (with repo read permissions)') parser.add_argument('--org', required=True, help='GitHub organization name') parser.add_argument('--path', help='Base path for repositories', default=None) parser.add_argument('--concurrent', type=int, default=50, help='Maximum number of concurrent operations') args = parser.parse_args() syncer = GitHubRepoSync(args.token, args.org, args.path, args.concurrent) await syncer.sync_all_repositories() def main(): asyncio.run(async_main()) if __name__ == '__main__': main()