Skip to content

Commit 9381897

Browse files
Merge pull request #4 from Zektopic/feature/clone-org-repos-cli
Feat: Add org repo cloning, CLI, and API handling
2 parents 0c7f8ae + 9eeea01 commit 9381897

4 files changed

Lines changed: 231 additions & 67 deletions

File tree

README.md

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
2-
# GitHub Starred Repository Cloner
1+
# GitHub Repository Cloner
32

43
![Python CI](.github/workflows/ci.yml/badge.svg)
54

6-
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.
5+
This Python script automates the process of cloning repositories from GitHub. It can clone all repositories starred by a user or all repositories from a specific organization.
76

87
## Features
9-
- Automatically fetches starred repositories.
10-
- Clones each repository locally.
8+
- Clone all starred repositories for a user.
9+
- Clone all repositories from a GitHub organization.
10+
- Command-line interface to specify what to clone.
11+
- Handles GitHub API pagination to fetch all repositories.
12+
- Includes rate limiting handling to avoid API request failures.
1113
- Skips already cloned repositories to avoid duplication.
1214

1315
## Requirements
1416
- Python 3.x
15-
- `requests` library (`pip install requests`)
16-
- `GitPython` library (`pip install gitpython`)
17+
- `requests` library
18+
- `GitPython` library
1719
- A GitHub Personal Access Token (PAT)
1820

1921
## Installation & Usage
@@ -29,19 +31,27 @@ cd clone-github-starred
2931
pip install -r requirements.txt
3032
```
3133

32-
### 3. Configure Your Credentials
33-
Edit `github_clone.py` and set your GitHub username and PAT:
34-
```python
35-
username = 'Enter Your Username'
36-
token = 'Enter Your PAT'
34+
### 3. Run the Script
35+
Execute the script using the command-line interface. You must provide a GitHub Personal Access Token (PAT) with the `--token` argument.
36+
37+
#### To clone starred repositories:
38+
Use the `--starred` argument with your GitHub username.
39+
```bash
40+
python github_clone.py --token YOUR_PAT --starred YOUR_USERNAME
3741
```
3842

39-
### 4. Run the Script
40-
Execute the script to clone starred repositories:
43+
#### To clone repositories from an organization:
44+
Use the `--org` argument with the organization name.
4145
```bash
42-
python github_clone.py
46+
python github_clone.py --token YOUR_PAT --org ORGANIZATION_NAME
4347
```
4448

49+
#### Optional Arguments:
50+
- `--clone-dir`: Specify a directory to clone the repositories into. Defaults to `repos`.
51+
```bash
52+
python github_clone.py --token YOUR_PAT --starred YOUR_USERNAME --clone-dir my_starred_repos
53+
```
54+
4555
## Troubleshooting
4656
- Ensure your PAT has the necessary permissions to read repository data.
4757
- Verify your network connection if cloning fails.
@@ -52,6 +62,3 @@ Feel free to submit issues or pull requests to enhance the functionality.
5262

5363
## License
5464
This project is licensed under the MIT License.
55-
56-
```
57-
```

github_clone.py

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,48 @@
11
import os
22
import requests
33
import git
4+
import argparse
5+
import time
6+
7+
def get_paged_data(url, headers):
8+
"""
9+
Fetches all pages of data from a paginated GitHub API endpoint.
10+
"""
11+
repos = []
12+
while url:
13+
response = requests.get(url, headers=headers)
14+
response.raise_for_status()
15+
repos.extend(response.json())
16+
17+
if 'next' in response.links:
18+
url = response.links['next']['url']
19+
else:
20+
url = None
21+
22+
# Handle rate limiting
23+
if int(response.headers.get('X-RateLimit-Remaining', 1)) == 0:
24+
reset_time = int(response.headers.get('X-RateLimit-Reset', 0))
25+
sleep_duration = max(0, reset_time - time.time())
26+
print(f"Rate limit exceeded. Waiting for {sleep_duration:.0f} seconds.")
27+
time.sleep(sleep_duration)
28+
29+
return repos
430

531
def get_starred_repos(username, token):
632
"""
733
Fetches the list of starred repositories for a given user.
834
"""
935
url = f'https://api.github.com/users/{username}/starred'
10-
response = requests.get(url, auth=(username, token))
11-
response.raise_for_status() # Raise an exception for bad status codes
12-
return response.json()
36+
headers = {'Authorization': f'token {token}'}
37+
return get_paged_data(url, headers)
38+
39+
def get_org_repos(org, token):
40+
"""
41+
Fetches the list of repositories for a given organization.
42+
"""
43+
url = f'https://api.github.com/orgs/{org}/repos'
44+
headers = {'Authorization': f'token {token}'}
45+
return get_paged_data(url, headers)
1346

1447
def clone_repo(repo_info, clone_dir):
1548
"""
@@ -21,35 +54,48 @@ def clone_repo(repo_info, clone_dir):
2154

2255
if not os.path.exists(repo_dir):
2356
print(f'Cloning {repo_name}...')
24-
git.Repo.clone_from(repo_url, repo_dir)
25-
print(f'Finished cloning {repo_name}')
57+
try:
58+
git.Repo.clone_from(repo_url, repo_dir)
59+
print(f'Finished cloning {repo_name}')
60+
except git.exc.GitCommandError as e:
61+
print(f"Error cloning {repo_name}: {e}")
2662
else:
2763
print(f'{repo_name} already exists, skipping...')
2864

2965
def main():
3066
"""
3167
Main function to clone starred GitHub repositories.
3268
"""
33-
# Your GitHub username
34-
username = 'Enter Your Username'
35-
# Example username = 'manupawickramasinghe'
69+
parser = argparse.ArgumentParser(description='Clone GitHub repositories.')
70+
parser.add_argument('--token', required=True, help='GitHub Personal Access Token.')
3671

37-
# Your GitHub personal access token
38-
token = 'Enter Your PAT'
39-
# Example token = '123123123133'
72+
group = parser.add_mutually_exclusive_group(required=True)
73+
group.add_argument('--starred', metavar='USERNAME', help='Clone starred repositories for a user.')
74+
group.add_argument('--org', metavar='ORG_NAME', help='Clone repositories from an organization.')
4075

41-
# Directory to clone repos into
42-
clone_dir = 'starred_repos'
76+
parser.add_argument('--clone-dir', default='repos', help='Directory to clone repositories into.')
77+
78+
args = parser.parse_args()
4379

4480
# Create the directory if it doesn't exist
45-
if not os.path.exists(clone_dir):
46-
os.makedirs(clone_dir)
81+
if not os.path.exists(args.clone_dir):
82+
os.makedirs(args.clone_dir)
4783

4884
try:
49-
repos = get_starred_repos(username, token)
85+
if args.starred:
86+
print(f"Fetching starred repositories for {args.starred}...")
87+
repos = get_starred_repos(args.starred, args.token)
88+
elif args.org:
89+
print(f"Fetching repositories for organization {args.org}...")
90+
repos = get_org_repos(args.org, args.token)
91+
92+
print(f"Found {len(repos)} repositories to clone.")
93+
5094
for repo in repos:
51-
clone_repo(repo, clone_dir)
52-
print('All repositories have been cloned.')
95+
clone_repo(repo, args.clone_dir)
96+
97+
print('All repositories have been processed.')
98+
5399
except requests.exceptions.RequestException as e:
54100
print(f"Error fetching repositories: {e}")
55101

requirements.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
certifi==2025.8.3
2+
charset-normalizer==3.4.3
3+
gitdb==4.0.12
4+
GitPython==3.1.45
5+
greenlet==3.2.3
6+
idna==3.10
7+
playwright==1.54.0
8+
pyee==13.0.0
9+
requests==2.32.5
10+
smmap==5.0.2
11+
typing_extensions==4.14.1
12+
urllib3==2.5.0

tests/test_github_clone.py

Lines changed: 130 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,166 @@
11
import unittest
2-
from unittest.mock import patch, MagicMock
2+
from unittest.mock import patch, MagicMock, call
33
import os
44
import sys
5+
import argparse
6+
import git
57

68
# Add the root directory to the Python path
79
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
810

9-
from github_clone import get_starred_repos, clone_repo
11+
from github_clone import get_paged_data, get_starred_repos, get_org_repos, clone_repo, main
1012

1113
class TestGitHubClone(unittest.TestCase):
1214

1315
@patch('github_clone.requests.get')
14-
def test_get_starred_repos_success(self, mock_get):
15-
# Mock the API response
16+
def test_get_paged_data_single_page(self, mock_get):
17+
# Mock the API response for a single page
1618
mock_response = MagicMock()
1719
mock_response.json.return_value = [{'name': 'repo1'}, {'name': 'repo2'}]
1820
mock_response.raise_for_status = MagicMock()
21+
mock_response.links = {}
22+
mock_response.headers = {'X-RateLimit-Remaining': '5000', 'X-RateLimit-Reset': '1678886400'}
1923
mock_get.return_value = mock_response
2024

21-
repos = get_starred_repos('testuser', 'testtoken')
25+
repos = get_paged_data('http://example.com/repos', {'Authorization': 'token test'})
2226
self.assertEqual(len(repos), 2)
23-
self.assertEqual(repos[0]['name'], 'repo1')
24-
mock_get.assert_called_with('https://api.github.com/users/testuser/starred', auth=('testuser', 'testtoken'))
27+
mock_get.assert_called_once_with('http://example.com/repos', headers={'Authorization': 'token test'})
2528

2629
@patch('github_clone.requests.get')
27-
def test_get_starred_repos_failure(self, mock_get):
28-
# Mock a failed API response
29-
mock_response = MagicMock()
30-
mock_response.raise_for_status.side_effect = Exception("API Error")
31-
mock_get.return_value = mock_response
30+
def test_get_paged_data_multiple_pages(self, mock_get):
31+
# Mock the API response for multiple pages
32+
mock_response1 = MagicMock()
33+
mock_response1.json.return_value = [{'name': 'repo1'}]
34+
mock_response1.raise_for_status = MagicMock()
35+
mock_response1.links = {'next': {'url': 'http://example.com/repos?page=2'}}
36+
mock_response1.headers = {'X-RateLimit-Remaining': '5000', 'X-RateLimit-Reset': '1678886400'}
37+
38+
mock_response2 = MagicMock()
39+
mock_response2.json.return_value = [{'name': 'repo2'}]
40+
mock_response2.raise_for_status = MagicMock()
41+
mock_response2.links = {}
42+
mock_response2.headers = {'X-RateLimit-Remaining': '4999', 'X-RateLimit-Reset': '1678886400'}
43+
44+
mock_get.side_effect = [mock_response1, mock_response2]
45+
46+
repos = get_paged_data('http://example.com/repos', {'Authorization': 'token test'})
47+
self.assertEqual(len(repos), 2)
48+
self.assertEqual(mock_get.call_count, 2)
49+
mock_get.assert_has_calls([
50+
call('http://example.com/repos', headers={'Authorization': 'token test'}),
51+
call('http://example.com/repos?page=2', headers={'Authorization': 'token test'})
52+
])
3253

33-
with self.assertRaises(Exception):
34-
get_starred_repos('testuser', 'testtoken')
54+
@patch('github_clone.time.sleep')
55+
@patch('github_clone.requests.get')
56+
def test_get_paged_data_rate_limiting(self, mock_get, mock_sleep):
57+
# Mock the API response for rate limiting
58+
# The time.time() call will be mocked to a fixed value.
59+
current_time = 1678886390.0
60+
reset_time = current_time + 10
61+
62+
mock_response1 = MagicMock()
63+
mock_response1.json.return_value = [{'name': 'repo1'}]
64+
mock_response1.raise_for_status = MagicMock()
65+
mock_response1.links = {'next': {'url': 'http://example.com/repos?page=2'}}
66+
mock_response1.headers = {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(reset_time))}
67+
68+
mock_response2 = MagicMock()
69+
mock_response2.json.return_value = [{'name': 'repo2'}]
70+
mock_response2.raise_for_status = MagicMock()
71+
mock_response2.links = {}
72+
mock_response2.headers = {'X-RateLimit-Remaining': '5000', 'X-RateLimit-Reset': str(int(reset_time) + 3600)}
73+
74+
mock_get.side_effect = [mock_response1, mock_response2]
75+
76+
with patch('time.time', return_value=current_time):
77+
get_paged_data('http://example.com/repos', {'Authorization': 'token test'})
78+
79+
mock_sleep.assert_called_once()
80+
# The sleep duration should be slightly more than 10 seconds
81+
self.assertAlmostEqual(mock_sleep.call_args[0][0], 10, delta=1)
82+
83+
@patch('github_clone.get_paged_data')
84+
def test_get_starred_repos(self, mock_get_paged_data):
85+
get_starred_repos('testuser', 'testtoken')
86+
mock_get_paged_data.assert_called_with(
87+
'https://api.github.com/users/testuser/starred',
88+
{'Authorization': 'token testtoken'}
89+
)
90+
91+
@patch('github_clone.get_paged_data')
92+
def test_get_org_repos(self, mock_get_paged_data):
93+
get_org_repos('testorg', 'testtoken')
94+
mock_get_paged_data.assert_called_with(
95+
'https://api.github.com/orgs/testorg/repos',
96+
{'Authorization': 'token testtoken'}
97+
)
3598

3699
@patch('github_clone.git.Repo.clone_from')
37100
@patch('github_clone.os.path.exists')
38101
def test_clone_repo_new(self, mock_exists, mock_clone_from):
39-
# Mock that the repo does not exist
40102
mock_exists.return_value = False
41-
42103
repo_info = {'name': 'new_repo', 'clone_url': 'http://example.com/new_repo.git'}
43-
clone_dir = 'test_dir'
44-
45-
clone_repo(repo_info, clone_dir)
46-
47-
repo_dir = os.path.join(clone_dir, repo_info['name'])
48-
mock_exists.assert_called_with(repo_dir)
49-
mock_clone_from.assert_called_with(repo_info['clone_url'], repo_dir)
104+
clone_repo(repo_info, 'test_dir')
105+
mock_clone_from.assert_called_with('http://example.com/new_repo.git', os.path.join('test_dir', 'new_repo'))
50106

51107
@patch('github_clone.git.Repo.clone_from')
52108
@patch('github_clone.os.path.exists')
53109
def test_clone_repo_exists(self, mock_exists, mock_clone_from):
54-
# Mock that the repo already exists
55110
mock_exists.return_value = True
56-
57111
repo_info = {'name': 'existing_repo', 'clone_url': 'http://example.com/existing_repo.git'}
58-
clone_dir = 'test_dir'
59-
60-
clone_repo(repo_info, clone_dir)
61-
62-
repo_dir = os.path.join(clone_dir, repo_info['name'])
63-
mock_exists.assert_called_with(repo_dir)
112+
clone_repo(repo_info, 'test_dir')
64113
mock_clone_from.assert_not_called()
65114

115+
@patch('github_clone.git.Repo.clone_from')
116+
@patch('github_clone.os.path.exists')
117+
def test_clone_repo_error(self, mock_exists, mock_clone_from):
118+
mock_exists.return_value = False
119+
mock_clone_from.side_effect = git.exc.GitCommandError('clone', 'error')
120+
repo_info = {'name': 'error_repo', 'clone_url': 'http://example.com/error_repo.git'}
121+
with patch('builtins.print') as mock_print:
122+
clone_repo(repo_info, 'test_dir')
123+
self.assertIn("Error cloning error_repo", mock_print.call_args_list[1][0][0])
124+
125+
@patch('github_clone.argparse.ArgumentParser.parse_args')
126+
@patch('github_clone.get_starred_repos')
127+
@patch('github_clone.clone_repo')
128+
@patch('github_clone.os.makedirs')
129+
@patch('github_clone.os.path.exists', return_value=False)
130+
def test_main_starred(self, mock_exists, mock_makedirs, mock_clone_repo, mock_get_starred_repos, mock_parse_args):
131+
mock_parse_args.return_value = argparse.Namespace(
132+
token='testtoken',
133+
starred='testuser',
134+
org=None,
135+
clone_dir='test_clone_dir'
136+
)
137+
mock_get_starred_repos.return_value = [{'name': 'repo1'}, {'name': 'repo2'}]
138+
139+
main()
140+
141+
mock_get_starred_repos.assert_called_with('testuser', 'testtoken')
142+
self.assertEqual(mock_clone_repo.call_count, 2)
143+
mock_makedirs.assert_called_with('test_clone_dir')
144+
145+
@patch('github_clone.argparse.ArgumentParser.parse_args')
146+
@patch('github_clone.get_org_repos')
147+
@patch('github_clone.clone_repo')
148+
@patch('github_clone.os.makedirs')
149+
@patch('github_clone.os.path.exists', return_value=False)
150+
def test_main_org(self, mock_exists, mock_makedirs, mock_clone_repo, mock_get_org_repos, mock_parse_args):
151+
mock_parse_args.return_value = argparse.Namespace(
152+
token='testtoken',
153+
starred=None,
154+
org='testorg',
155+
clone_dir='test_clone_dir'
156+
)
157+
mock_get_org_repos.return_value = [{'name': 'repo1'}, {'name': 'repo2'}]
158+
159+
main()
160+
161+
mock_get_org_repos.assert_called_with('testorg', 'testtoken')
162+
self.assertEqual(mock_clone_repo.call_count, 2)
163+
mock_makedirs.assert_called_with('test_clone_dir')
164+
66165
if __name__ == '__main__':
67166
unittest.main()

0 commit comments

Comments
 (0)