From 32907833c8e6d23c19836c0201739c71aa5d1797 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 13:08:23 +0000 Subject: [PATCH] feat: Add unit tests and CI workflow This commit introduces unit tests for the repository cloning script. - The main script `github-clone.py` is renamed to `github_clone.py` and refactored into functions to be testable. - Unit tests are added in the `tests/` directory using the `unittest` framework. - A `tests/README.md` is added to explain how to run the tests. - A GitHub Actions workflow is set up to run the tests automatically on push and pull requests. - A status badge for the CI workflow is added to the main `README.md`. - A `.gitignore` file is added to exclude compiled Python files and other common temporary files. --- .github/workflows/ci.yml | 28 +++++++++ .gitignore | 114 +++++++++++++++++++++++++++++++++++++ README.md | 6 +- github-clone.py | 41 ------------- github_clone.py | 57 +++++++++++++++++++ tests/README.md | 28 +++++++++ tests/__init__.py | 0 tests/test_github_clone.py | 67 ++++++++++++++++++++++ 8 files changed, 298 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore delete mode 100644 github-clone.py create mode 100644 github_clone.py create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/test_github_clone.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a865b36 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: Python CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests gitpython + + - name: Run tests + run: | + python -m unittest discover tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a47f5ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,114 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.pyc +*.pyo +*.pyd + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyderworkspace + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/README.md b/README.md index 4dd16bd..c2d20a5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # GitHub Starred Repository Cloner +![Python CI](.github/workflows/ci.yml/badge.svg) + This Python script automates the process of cloning all repositories starred by a GitHub user. Simply provide your GitHub username and Personal Access Token (PAT), and it will fetch and clone the repositories into a local directory. ## Features @@ -28,7 +30,7 @@ pip install -r requirements.txt ``` ### 3. Configure Your Credentials -Edit `github-clone.py` and set your GitHub username and PAT: +Edit `github_clone.py` and set your GitHub username and PAT: ```python username = 'Enter Your Username' token = 'Enter Your PAT' @@ -37,7 +39,7 @@ token = 'Enter Your PAT' ### 4. Run the Script Execute the script to clone starred repositories: ```bash -python github-clone.py +python github_clone.py ``` ## Troubleshooting diff --git a/github-clone.py b/github-clone.py deleted file mode 100644 index 7af51dc..0000000 --- a/github-clone.py +++ /dev/null @@ -1,41 +0,0 @@ -import requests -import git -import os - -# Your GitHub username -username = 'Enter Your Username' -# Example username = 'manupawickramasinghe' - -# Your GitHub personal access token -token = 'enter your PAT' - -# Example token = '123123123133' - -# GitHub API URL for starred repos -url = f'https://api.github.com/users/{username}/starred' - -# Directory to clone repos into -clone_dir = 'starred_repos' - -# Create the directory if it doesn't exist -if not os.path.exists(clone_dir): - os.makedirs(clone_dir) - -# Fetch starred repos -response = requests.get(url, auth=(username, token)) -repos = response.json() - -# Clone each repo -for repo in repos: - repo_name = repo['name'] - repo_url = repo['clone_url'] - repo_dir = os.path.join(clone_dir, repo_name) - - if not os.path.exists(repo_dir): - print(f'Cloning {repo_name}...') - git.Repo.clone_from(repo_url, repo_dir) - print(f'Finished cloning {repo_name}') - else: - print(f'{repo_name} already exists, skipping...') - -print('All repositories have been cloned.') diff --git a/github_clone.py b/github_clone.py new file mode 100644 index 0000000..49ae214 --- /dev/null +++ b/github_clone.py @@ -0,0 +1,57 @@ +import os +import requests +import git + +def get_starred_repos(username, token): + """ + Fetches the list of starred repositories for a given user. + """ + url = f'https://api.github.com/users/{username}/starred' + response = requests.get(url, auth=(username, token)) + response.raise_for_status() # Raise an exception for bad status codes + return response.json() + +def clone_repo(repo_info, clone_dir): + """ + Clones a single repository into the specified directory. + """ + repo_name = repo_info['name'] + repo_url = repo_info['clone_url'] + repo_dir = os.path.join(clone_dir, repo_name) + + if not os.path.exists(repo_dir): + print(f'Cloning {repo_name}...') + git.Repo.clone_from(repo_url, repo_dir) + print(f'Finished cloning {repo_name}') + else: + print(f'{repo_name} already exists, skipping...') + +def main(): + """ + Main function to clone starred GitHub repositories. + """ + # Your GitHub username + username = 'Enter Your Username' + # Example username = 'manupawickramasinghe' + + # Your GitHub personal access token + token = 'Enter Your PAT' + # Example token = '123123123133' + + # Directory to clone repos into + clone_dir = 'starred_repos' + + # Create the directory if it doesn't exist + if not os.path.exists(clone_dir): + os.makedirs(clone_dir) + + try: + repos = get_starred_repos(username, token) + for repo in repos: + clone_repo(repo, clone_dir) + print('All repositories have been cloned.') + except requests.exceptions.RequestException as e: + print(f"Error fetching repositories: {e}") + +if __name__ == "__main__": + main() diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..5a957af --- /dev/null +++ b/tests/README.md @@ -0,0 +1,28 @@ +# Unit Tests + +This directory contains the unit tests for the GitHub Starred Repository Cloner. + +## Framework + +The tests are written using Python's built-in `unittest` framework. The `unittest.mock` library is used to simulate external dependencies like API calls and file system operations. + +## Running the Tests + +To run the tests, navigate to the root directory of the project and run the following command: + +```bash +python -m unittest discover tests +``` + +This will automatically discover and run all the tests in this directory. + +## Test Coverage + +The tests cover the following functionality: + +- **`get_starred_repos`**: + - Verifies that the function correctly parses a successful API response. + - Ensures that the function handles API errors gracefully. +- **`clone_repo`**: + - Checks that a new repository is cloned if it doesn't already exist. + - Confirms that an existing repository is skipped. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_github_clone.py b/tests/test_github_clone.py new file mode 100644 index 0000000..c1626dd --- /dev/null +++ b/tests/test_github_clone.py @@ -0,0 +1,67 @@ +import unittest +from unittest.mock import patch, MagicMock +import os +import sys + +# Add the root directory to the Python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from github_clone import get_starred_repos, clone_repo + +class TestGitHubClone(unittest.TestCase): + + @patch('github_clone.requests.get') + def test_get_starred_repos_success(self, mock_get): + # Mock the API response + mock_response = MagicMock() + mock_response.json.return_value = [{'name': 'repo1'}, {'name': 'repo2'}] + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + repos = get_starred_repos('testuser', 'testtoken') + self.assertEqual(len(repos), 2) + self.assertEqual(repos[0]['name'], 'repo1') + mock_get.assert_called_with('https://api.github.com/users/testuser/starred', auth=('testuser', 'testtoken')) + + @patch('github_clone.requests.get') + def test_get_starred_repos_failure(self, mock_get): + # Mock a failed API response + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = Exception("API Error") + mock_get.return_value = mock_response + + with self.assertRaises(Exception): + get_starred_repos('testuser', 'testtoken') + + @patch('github_clone.git.Repo.clone_from') + @patch('github_clone.os.path.exists') + def test_clone_repo_new(self, mock_exists, mock_clone_from): + # Mock that the repo does not exist + mock_exists.return_value = False + + repo_info = {'name': 'new_repo', 'clone_url': 'http://example.com/new_repo.git'} + clone_dir = 'test_dir' + + clone_repo(repo_info, clone_dir) + + repo_dir = os.path.join(clone_dir, repo_info['name']) + mock_exists.assert_called_with(repo_dir) + mock_clone_from.assert_called_with(repo_info['clone_url'], repo_dir) + + @patch('github_clone.git.Repo.clone_from') + @patch('github_clone.os.path.exists') + def test_clone_repo_exists(self, mock_exists, mock_clone_from): + # Mock that the repo already exists + mock_exists.return_value = True + + repo_info = {'name': 'existing_repo', 'clone_url': 'http://example.com/existing_repo.git'} + clone_dir = 'test_dir' + + clone_repo(repo_info, clone_dir) + + repo_dir = os.path.join(clone_dir, repo_info['name']) + mock_exists.assert_called_with(repo_dir) + mock_clone_from.assert_not_called() + +if __name__ == '__main__': + unittest.main()