github-repository-sync 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: github-repository-sync
3
+ Version: 0.0.1
4
+ Summary: Find and clone all repositories for a GitHub organization.
5
+ Author: Kaizten Analytics
6
+ License-Expression: LicenseRef-Proprietary
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # GitHub Organization Repository Sync
13
+
14
+ `github-repository-sync.py` finds all repositories for a GitHub organization and checks whether they already exist locally under a recursive search root.
15
+
16
+ Repositories that are missing locally are cloned into `<root>/<org>/<repo>` by default. The script detects existing local repositories by inspecting their Git remotes, so repositories can be located in nested folders.
17
+
18
+ The script uses named arguments only. There are no positional arguments.
19
+
20
+ ## Dependencies
21
+
22
+ - Python 3.10 or newer
23
+ - Git
24
+ - GitHub CLI (`gh`), recommended for private repositories
25
+
26
+ Authentication options:
27
+
28
+ - Use an authenticated GitHub CLI session with `gh auth login`.
29
+ - Or set `GITHUB_TOKEN` or `GH_TOKEN`.
30
+ - Or pass a token with `--token`.
31
+
32
+ Without authentication, GitHub only returns public repositories.
33
+
34
+ ## How to use
35
+
36
+ Run the script with the organization name:
37
+
38
+ ```bash
39
+ ./github-repository-sync.py --org kaizten
40
+ ```
41
+
42
+ By default, the script searches recursively under `~` and clones missing
43
+ repositories into `~/kaizten`.
44
+
45
+ Use `--dry-run` to preview the missing repositories and clone destinations
46
+ without cloning anything:
47
+
48
+ ```bash
49
+ ./github-repository-sync.py --org kaizten --dry-run
50
+ ```
51
+
52
+ Use `--root` to search and clone under a different directory:
53
+
54
+ ```bash
55
+ ./github-repository-sync.py --org kaizten --root /home/christopher
56
+ ```
57
+
58
+ Use `--clone-dir` to clone missing repositories somewhere other than
59
+ `<root>/<org>`:
60
+
61
+ ```bash
62
+ ./github-repository-sync.py --org kaizten --clone-dir /home/christopher/repos
63
+ ```
64
+
65
+ If a clone destination already exists and is not empty, the script chooses the
66
+ next available suffixed directory, such as `repo-1`.
67
+
68
+ ## Examples
69
+
70
+ Preview missing repositories for `kaizten`:
71
+
72
+ ```bash
73
+ ./github-repository-sync.py --org kaizten --dry-run
74
+ ```
75
+
76
+ Clone all missing repositories for `kaizten` under the default root:
77
+
78
+ ```bash
79
+ ./github-repository-sync.py --org kaizten
80
+ ```
81
+
82
+ Search under `/home/christopher` and clone missing repositories under
83
+ `/home/christopher/kaizten`:
84
+
85
+ ```bash
86
+ ./github-repository-sync.py --org kaizten --root /home/christopher
87
+ ```
88
+
89
+ Clone using SSH URLs:
90
+
91
+ ```bash
92
+ ./github-repository-sync.py --org kaizten --protocol ssh
93
+ ```
94
+
95
+ Skip archived repositories:
96
+
97
+ ```bash
98
+ ./github-repository-sync.py --org kaizten --exclude-archived
99
+ ```
100
+
101
+ Use an explicit GitHub token:
102
+
103
+ ```bash
104
+ ./github-repository-sync.py --org kaizten --token YOUR_GITHUB_TOKEN
105
+ ```
@@ -0,0 +1,6 @@
1
+ github_repository_sync.py,sha256=iTazVcIaJJMyZEmHXNNzdnbLA1u8pujnFE0MdcHAWcQ,12384
2
+ github_repository_sync-0.0.1.dist-info/METADATA,sha256=aucU8VOJhcexwrT1bNtC2IFIVF3wCVwDqtZqv_KdaTY,2767
3
+ github_repository_sync-0.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
4
+ github_repository_sync-0.0.1.dist-info/entry_points.txt,sha256=Jpx2WqZQVFYwUL-wDve5fC-v4l4kUqI4ovX_G59klqk,71
5
+ github_repository_sync-0.0.1.dist-info/top_level.txt,sha256=52t4ReaXYcSgDfrB0PjrelM6Zav7M1bDp_l87TSyGck,23
6
+ github_repository_sync-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ github-repository-sync = github_repository_sync:main
@@ -0,0 +1 @@
1
+ github_repository_sync
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env python3
2
+ """Find and clone all repositories for a GitHub organization.
3
+
4
+ The script discovers local clones recursively under a root directory by
5
+ inspecting git remotes. Any organization repository that is not already present
6
+ locally is cloned into <root>/<org>/<repo> by default.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+
25
+ GITHUB_API = "https://api.github.com"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class GitHubRepo:
30
+ name: str
31
+ ssh_url: str
32
+ clone_url: str
33
+ archived: bool
34
+ disabled: bool
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class CloneResult:
39
+ repo_name: str
40
+ destination: Path
41
+ status: str
42
+
43
+
44
+ def parse_args() -> argparse.Namespace:
45
+ parser = argparse.ArgumentParser(
46
+ description="Recursively find local clones for a GitHub organization and clone missing repositories.",
47
+ )
48
+ parser.add_argument(
49
+ "--org",
50
+ required=True,
51
+ help="GitHub organization name. Example: kaizten",
52
+ )
53
+ parser.add_argument(
54
+ "--root",
55
+ type=Path,
56
+ default=Path.home(),
57
+ help="Directory to search recursively and clone into. Defaults to ~.",
58
+ )
59
+ parser.add_argument(
60
+ "--token",
61
+ default=os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"),
62
+ help="GitHub token. Defaults to GITHUB_TOKEN or GH_TOKEN when set.",
63
+ )
64
+ parser.add_argument(
65
+ "--no-gh-cli",
66
+ action="store_true",
67
+ help="Do not use the GitHub CLI as an authentication fallback.",
68
+ )
69
+ parser.add_argument(
70
+ "--github-api",
71
+ default=GITHUB_API,
72
+ help="GitHub API base URL. Defaults to https://api.github.com.",
73
+ )
74
+ parser.add_argument(
75
+ "--protocol",
76
+ choices=("https", "ssh"),
77
+ default="https",
78
+ help="Clone protocol for missing repositories. Defaults to https.",
79
+ )
80
+ parser.add_argument(
81
+ "--clone-dir",
82
+ type=Path,
83
+ help="Directory where missing repositories are cloned. Defaults to <root>/<org>.",
84
+ )
85
+ parser.add_argument(
86
+ "--include-archived",
87
+ action="store_true",
88
+ help="Include archived repositories. This is the default; kept for compatibility.",
89
+ )
90
+ parser.add_argument(
91
+ "--exclude-archived",
92
+ action="store_true",
93
+ help="Skip archived repositories.",
94
+ )
95
+ parser.add_argument(
96
+ "--dry-run",
97
+ action="store_true",
98
+ help="Print what would be cloned without running git clone.",
99
+ )
100
+ return parser.parse_args()
101
+
102
+
103
+ def run_git(args: list[str], cwd: Path | None = None) -> str:
104
+ completed = subprocess.run(
105
+ ["git", *args],
106
+ cwd=cwd,
107
+ check=True,
108
+ text=True,
109
+ stdout=subprocess.PIPE,
110
+ stderr=subprocess.PIPE,
111
+ )
112
+ return completed.stdout.strip()
113
+
114
+
115
+ def is_git_repository(path: Path) -> bool:
116
+ return (path / ".git").exists()
117
+
118
+
119
+ def iter_git_repositories(root: Path) -> list[Path]:
120
+ repos: list[Path] = []
121
+ ignored_dir_names = {".git", ".hg", ".svn", "node_modules", ".venv", "venv", "__pycache__"}
122
+
123
+ for current_root, dir_names, _file_names in os.walk(root):
124
+ dir_names[:] = [name for name in dir_names if name not in ignored_dir_names]
125
+ current_path = Path(current_root)
126
+
127
+ if is_git_repository(current_path):
128
+ repos.append(current_path)
129
+
130
+ return repos
131
+
132
+
133
+ def normalize_remote_url(url: str) -> tuple[str, str] | None:
134
+ url = url.strip()
135
+ if not url:
136
+ return None
137
+
138
+ scp_match = re.match(r"^git@[^:]+:([^/]+)/(.+?)(?:\.git)?$", url)
139
+ if scp_match:
140
+ return scp_match.group(1).lower(), scp_match.group(2).lower()
141
+
142
+ parsed = urllib.parse.urlparse(url)
143
+ if parsed.scheme and parsed.netloc:
144
+ parts = [part for part in parsed.path.strip("/").split("/") if part]
145
+ if len(parts) >= 2:
146
+ repo_name = re.sub(r"\.git$", "", parts[1], flags=re.IGNORECASE)
147
+ return parts[0].lower(), repo_name.lower()
148
+
149
+ return None
150
+
151
+
152
+ def repository_remotes(repo_path: Path) -> set[tuple[str, str]]:
153
+ try:
154
+ output = run_git(["remote", "-v"], cwd=repo_path)
155
+ except (subprocess.CalledProcessError, FileNotFoundError):
156
+ return set()
157
+
158
+ remotes: set[tuple[str, str]] = set()
159
+ for line in output.splitlines():
160
+ parts = line.split()
161
+ if len(parts) >= 2:
162
+ normalized = normalize_remote_url(parts[1])
163
+ if normalized:
164
+ remotes.add(normalized)
165
+ return remotes
166
+
167
+
168
+ def local_repositories_by_name(root: Path, org: str) -> dict[str, Path]:
169
+ local: dict[str, Path] = {}
170
+ expected_owner = org.lower()
171
+
172
+ for repo_path in iter_git_repositories(root):
173
+ for owner, repo_name in repository_remotes(repo_path):
174
+ if owner == expected_owner:
175
+ local.setdefault(repo_name, repo_path)
176
+
177
+ return local
178
+
179
+
180
+ def github_request(url: str, token: str | None) -> tuple[list[dict], str | None]:
181
+ headers = {
182
+ "Accept": "application/vnd.github+json",
183
+ "User-Agent": "github-repository-sync",
184
+ "X-GitHub-Api-Version": "2022-11-28",
185
+ }
186
+ if token:
187
+ headers["Authorization"] = f"Bearer {token}"
188
+
189
+ request = urllib.request.Request(url, headers=headers)
190
+ try:
191
+ with urllib.request.urlopen(request) as response:
192
+ payload = json.loads(response.read().decode("utf-8"))
193
+ next_url = parse_next_link(response.headers.get("Link", ""))
194
+ return payload, next_url
195
+ except urllib.error.HTTPError as error:
196
+ message = error.read().decode("utf-8", errors="replace")
197
+ raise RuntimeError(f"GitHub API request failed ({error.code}): {message}") from error
198
+ except urllib.error.URLError as error:
199
+ raise RuntimeError(f"GitHub API request failed: {error.reason}") from error
200
+
201
+
202
+ def parse_next_link(link_header: str) -> str | None:
203
+ for item in link_header.split(","):
204
+ section = item.strip()
205
+ if 'rel="next"' not in section:
206
+ continue
207
+ match = re.match(r"<([^>]+)>", section)
208
+ if match:
209
+ return match.group(1)
210
+ return None
211
+
212
+
213
+ def fetch_org_repositories(api_base: str, org: str, token: str | None) -> list[GitHubRepo]:
214
+ encoded_org = urllib.parse.quote(org, safe="")
215
+ url: str | None = f"{api_base.rstrip('/')}/orgs/{encoded_org}/repos?per_page=100&type=all"
216
+ repos: list[GitHubRepo] = []
217
+
218
+ while url:
219
+ payload, url = github_request(url, token)
220
+ for item in payload:
221
+ repos.append(
222
+ GitHubRepo(
223
+ name=item["name"],
224
+ ssh_url=item["ssh_url"],
225
+ clone_url=item["clone_url"],
226
+ archived=bool(item.get("archived")),
227
+ disabled=bool(item.get("disabled")),
228
+ )
229
+ )
230
+
231
+ return repos
232
+
233
+
234
+ def fetch_org_repositories_with_gh(org: str) -> list[GitHubRepo]:
235
+ if not shutil.which("gh"):
236
+ return []
237
+
238
+ completed = subprocess.run(
239
+ [
240
+ "gh",
241
+ "api",
242
+ "--paginate",
243
+ f"/orgs/{org}/repos?per_page=100&type=all",
244
+ "--jq",
245
+ ".[] | [.name, .ssh_url, .clone_url, .archived, .disabled] | @tsv",
246
+ ],
247
+ check=False,
248
+ text=True,
249
+ stdout=subprocess.PIPE,
250
+ stderr=subprocess.PIPE,
251
+ )
252
+ if completed.returncode != 0:
253
+ return []
254
+
255
+ repos: list[GitHubRepo] = []
256
+ for line in completed.stdout.splitlines():
257
+ if not line.strip():
258
+ continue
259
+ fields = line.split("\t")
260
+ if len(fields) != 5:
261
+ continue
262
+ repos.append(
263
+ GitHubRepo(
264
+ name=fields[0],
265
+ ssh_url=fields[1],
266
+ clone_url=fields[2],
267
+ archived=fields[3] == "true",
268
+ disabled=fields[4] == "true",
269
+ )
270
+ )
271
+ return repos
272
+
273
+
274
+ def available_clone_destination(clone_dir: Path, repo_name: str) -> Path:
275
+ destination = clone_dir / repo_name
276
+ if not destination.exists() or not any(destination.iterdir()):
277
+ return destination
278
+
279
+ suffix = 1
280
+ while True:
281
+ candidate = clone_dir / f"{repo_name}-{suffix}"
282
+ if not candidate.exists() or not any(candidate.iterdir()):
283
+ return candidate
284
+ suffix += 1
285
+
286
+
287
+ def print_repository_list(title: str, repos: list[CloneResult]) -> None:
288
+ print(title)
289
+ for result in repos:
290
+ print(f"- {result.repo_name}: {result.destination}")
291
+
292
+
293
+ def clone_repository(
294
+ repo: GitHubRepo,
295
+ org: str,
296
+ clone_dir: Path,
297
+ protocol: str,
298
+ dry_run: bool,
299
+ use_gh_clone: bool,
300
+ ) -> CloneResult:
301
+ default_destination = clone_dir / repo.name
302
+ destination = available_clone_destination(clone_dir, repo.name)
303
+ source = repo.ssh_url if protocol == "ssh" else repo.clone_url
304
+
305
+ if destination != default_destination:
306
+ print(
307
+ f"warning: {default_destination} is not empty; cloning {repo.name} into {destination}",
308
+ file=sys.stderr,
309
+ )
310
+
311
+ print(f"clone: {repo.name} -> {destination}")
312
+ if dry_run:
313
+ return CloneResult(repo.name, destination, "dry-run")
314
+
315
+ destination.parent.mkdir(parents=True, exist_ok=True)
316
+ command = (
317
+ ["gh", "repo", "clone", f"{org}/{repo.name}", str(destination)]
318
+ if use_gh_clone
319
+ else ["git", "clone", source, str(destination)]
320
+ )
321
+ try:
322
+ subprocess.run(command, check=True)
323
+ return CloneResult(repo.name, destination, "cloned")
324
+ except subprocess.CalledProcessError as error:
325
+ print(f"error: failed to clone {repo.name} ({error})", file=sys.stderr)
326
+ return CloneResult(repo.name, destination, "failed")
327
+
328
+
329
+ def main() -> int:
330
+ args = parse_args()
331
+ root = args.root.expanduser().resolve()
332
+ clone_dir = (args.clone_dir or root / args.org).expanduser().resolve()
333
+
334
+ if not root.exists():
335
+ print(f"error: root directory does not exist: {root}", file=sys.stderr)
336
+ return 2
337
+
338
+ print(f"searching local repositories under {root}")
339
+ local_repos = local_repositories_by_name(root, args.org)
340
+ print(f"found {len(local_repos)} local repositories for {args.org}")
341
+
342
+ if not args.token and not args.no_gh_cli:
343
+ org_repos = fetch_org_repositories_with_gh(args.org)
344
+ if org_repos:
345
+ source = "GitHub CLI"
346
+ else:
347
+ org_repos = fetch_org_repositories(args.github_api, args.org, args.token)
348
+ source = "GitHub API"
349
+ else:
350
+ org_repos = fetch_org_repositories(args.github_api, args.org, args.token)
351
+ source = "GitHub API"
352
+
353
+ active_repos = [
354
+ repo
355
+ for repo in org_repos
356
+ if not repo.disabled and not (args.exclude_archived and repo.archived)
357
+ ]
358
+ print(f"found {len(active_repos)} organization repositories from {source}")
359
+ if not args.token and source == "GitHub API":
360
+ print(
361
+ "warning: no GitHub token was provided and GitHub CLI authentication was not usable; "
362
+ "private repositories will not be listed",
363
+ file=sys.stderr,
364
+ )
365
+
366
+ missing = [repo for repo in active_repos if repo.name.lower() not in local_repos]
367
+ if not missing:
368
+ print("all organization repositories are already present locally")
369
+ return 0
370
+
371
+ print(f"{len(missing)} repositories missing locally")
372
+ planned = [
373
+ CloneResult(repo.name, available_clone_destination(clone_dir, repo.name), "pending")
374
+ for repo in missing
375
+ ]
376
+ print_repository_list("missing repositories:", planned)
377
+
378
+ results: list[CloneResult] = []
379
+ use_gh_clone = source == "GitHub CLI" and not args.no_gh_cli
380
+ for repo in missing:
381
+ results.append(
382
+ clone_repository(repo, args.org, clone_dir, args.protocol, args.dry_run, use_gh_clone)
383
+ )
384
+
385
+ print("summary:")
386
+ for status in ("cloned", "dry-run", "failed"):
387
+ status_results = [result for result in results if result.status == status]
388
+ if status_results:
389
+ print_repository_list(f"{status} repositories ({len(status_results)}):", status_results)
390
+
391
+ failures = sum(1 for result in results if result.status == "failed")
392
+ return 1 if failures else 0
393
+
394
+
395
+ if __name__ == "__main__":
396
+ raise SystemExit(main())