Skip to content

Commit 0cfc549

Browse files
Claude/language comparison table oog22 (#5)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d9d539 commit 0cfc549

1 file changed

Lines changed: 280 additions & 0 deletions

File tree

scripts/repo-reconcile.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Repo Reconciliation Tool
4+
Compares GitLab and GitHub repositories to identify sync gaps.
5+
6+
Usage:
7+
export GITLAB_TOKEN="your-token"
8+
export GITHUB_TOKEN="your-token"
9+
python repo-reconcile.py
10+
11+
Outputs a reconciliation report showing:
12+
- Repos only on GitLab (need to mirror to GitHub)
13+
- Repos only on GitHub (new creations or need to mirror to GitLab)
14+
- Repos on both (check if synced or diverged)
15+
"""
16+
17+
import os
18+
import json
19+
import requests
20+
from datetime import datetime
21+
from dataclasses import dataclass
22+
from typing import Optional
23+
24+
# Configuration
25+
GITLAB_USER = "hyperpolymath"
26+
GITLAB_GROUPS = ["maa-framework"] # Add more groups as needed
27+
GITHUB_USER = "hyperpolymath"
28+
29+
@dataclass
30+
class Repo:
31+
name: str
32+
platform: str
33+
url: str
34+
last_activity: Optional[str]
35+
commit_count: Optional[int] = None
36+
description: Optional[str] = None
37+
38+
def get_gitlab_repos(token: str) -> list[Repo]:
39+
"""Fetch all repos from GitLab user and groups."""
40+
repos = []
41+
headers = {"PRIVATE-TOKEN": token}
42+
43+
# User repos
44+
page = 1
45+
while True:
46+
url = f"https://gitlab.com/api/v4/users/{GITLAB_USER}/projects?per_page=100&page={page}"
47+
resp = requests.get(url, headers=headers)
48+
if resp.status_code != 200:
49+
print(f"GitLab API error: {resp.status_code}")
50+
break
51+
data = resp.json()
52+
if not data:
53+
break
54+
for r in data:
55+
repos.append(Repo(
56+
name=r["path"],
57+
platform="gitlab",
58+
url=r["web_url"],
59+
last_activity=r.get("last_activity_at"),
60+
description=r.get("description")
61+
))
62+
page += 1
63+
64+
# Group repos
65+
for group in GITLAB_GROUPS:
66+
page = 1
67+
while True:
68+
url = f"https://gitlab.com/api/v4/groups/{group}/projects?per_page=100&page={page}&include_subgroups=true"
69+
resp = requests.get(url, headers=headers)
70+
if resp.status_code != 200:
71+
break
72+
data = resp.json()
73+
if not data:
74+
break
75+
for r in data:
76+
repos.append(Repo(
77+
name=r["path"],
78+
platform="gitlab",
79+
url=r["web_url"],
80+
last_activity=r.get("last_activity_at"),
81+
description=r.get("description")
82+
))
83+
page += 1
84+
85+
return repos
86+
87+
def get_github_repos(token: str) -> list[Repo]:
88+
"""Fetch all repos from GitHub user."""
89+
repos = []
90+
headers = {"Authorization": f"token {token}"}
91+
92+
page = 1
93+
while True:
94+
url = f"https://api.github.com/users/{GITHUB_USER}/repos?per_page=100&page={page}"
95+
resp = requests.get(url, headers=headers)
96+
if resp.status_code != 200:
97+
print(f"GitHub API error: {resp.status_code}")
98+
break
99+
data = resp.json()
100+
if not data:
101+
break
102+
for r in data:
103+
repos.append(Repo(
104+
name=r["name"],
105+
platform="github",
106+
url=r["html_url"],
107+
last_activity=r.get("pushed_at"),
108+
description=r.get("description")
109+
))
110+
page += 1
111+
112+
return repos
113+
114+
def reconcile(gitlab_repos: list[Repo], github_repos: list[Repo]) -> dict:
115+
"""Compare repos and categorize sync status."""
116+
gitlab_names = {r.name: r for r in gitlab_repos}
117+
github_names = {r.name: r for r in github_repos}
118+
119+
all_names = set(gitlab_names.keys()) | set(github_names.keys())
120+
121+
result = {
122+
"gitlab_only": [],
123+
"github_only": [],
124+
"both_synced": [],
125+
"both_diverged": [],
126+
"unknown": []
127+
}
128+
129+
for name in sorted(all_names):
130+
gl = gitlab_names.get(name)
131+
gh = github_names.get(name)
132+
133+
if gl and not gh:
134+
result["gitlab_only"].append({
135+
"name": name,
136+
"gitlab_url": gl.url,
137+
"last_activity": gl.last_activity,
138+
"action": "Mirror to GitHub"
139+
})
140+
elif gh and not gl:
141+
result["github_only"].append({
142+
"name": name,
143+
"github_url": gh.url,
144+
"last_activity": gh.last_activity,
145+
"action": "New on GitHub (mirror to GitLab?)"
146+
})
147+
else:
148+
# Both exist - check if diverged
149+
gl_time = gl.last_activity or ""
150+
gh_time = gh.last_activity or ""
151+
152+
# Simple heuristic: if times differ by > 1 day, flag as potentially diverged
153+
try:
154+
gl_dt = datetime.fromisoformat(gl_time.replace("Z", "+00:00"))
155+
gh_dt = datetime.fromisoformat(gh_time.replace("Z", "+00:00"))
156+
diff = abs((gl_dt - gh_dt).days)
157+
158+
if diff > 1:
159+
ahead = "GitLab" if gl_dt > gh_dt else "GitHub"
160+
result["both_diverged"].append({
161+
"name": name,
162+
"gitlab_url": gl.url,
163+
"github_url": gh.url,
164+
"gitlab_activity": gl_time,
165+
"github_activity": gh_time,
166+
"ahead": ahead,
167+
"days_apart": diff,
168+
"action": f"Check sync - {ahead} is {diff} days ahead"
169+
})
170+
else:
171+
result["both_synced"].append({
172+
"name": name,
173+
"status": "Likely synced"
174+
})
175+
except:
176+
result["unknown"].append({
177+
"name": name,
178+
"gitlab_url": gl.url if gl else None,
179+
"github_url": gh.url if gh else None,
180+
"action": "Could not compare dates"
181+
})
182+
183+
return result
184+
185+
def print_report(result: dict):
186+
"""Print human-readable reconciliation report."""
187+
print("\n" + "="*70)
188+
print("REPOSITORY RECONCILIATION REPORT")
189+
print("="*70)
190+
191+
print(f"\n## GITLAB ONLY ({len(result['gitlab_only'])} repos)")
192+
print("These repos exist on GitLab but NOT on GitHub:")
193+
for r in result["gitlab_only"][:20]: # Show first 20
194+
print(f" - {r['name']}: {r['gitlab_url']}")
195+
if len(result["gitlab_only"]) > 20:
196+
print(f" ... and {len(result['gitlab_only']) - 20} more")
197+
198+
print(f"\n## GITHUB ONLY ({len(result['github_only'])} repos)")
199+
print("These repos exist on GitHub but NOT on GitLab:")
200+
for r in result["github_only"][:20]:
201+
print(f" - {r['name']}: {r['github_url']}")
202+
if len(result["github_only"]) > 20:
203+
print(f" ... and {len(result['github_only']) - 20} more")
204+
205+
print(f"\n## POTENTIALLY DIVERGED ({len(result['both_diverged'])} repos)")
206+
print("These repos exist on both but may be out of sync:")
207+
for r in result["both_diverged"]:
208+
print(f" - {r['name']}: {r['ahead']} ahead by {r['days_apart']} days")
209+
210+
print(f"\n## LIKELY SYNCED ({len(result['both_synced'])} repos)")
211+
212+
print("\n" + "="*70)
213+
print("SUMMARY")
214+
print("="*70)
215+
print(f" GitLab only: {len(result['gitlab_only']):4d} (need to mirror to GitHub)")
216+
print(f" GitHub only: {len(result['github_only']):4d} (new on GitHub)")
217+
print(f" Diverged: {len(result['both_diverged']):4d} (need sync check)")
218+
print(f" Synced: {len(result['both_synced']):4d} (OK)")
219+
print(f" Unknown: {len(result['unknown']):4d}")
220+
221+
def main():
222+
gitlab_token = os.environ.get("GITLAB_TOKEN")
223+
github_token = os.environ.get("GITHUB_TOKEN")
224+
225+
if not gitlab_token or not github_token:
226+
print("Please set GITLAB_TOKEN and GITHUB_TOKEN environment variables")
227+
print("\nTo get tokens:")
228+
print(" GitLab: Settings > Access Tokens > Create with 'read_api' scope")
229+
print(" GitHub: Settings > Developer settings > Personal access tokens")
230+
return
231+
232+
print("Fetching GitLab repos...")
233+
gitlab_repos = get_gitlab_repos(gitlab_token)
234+
print(f" Found {len(gitlab_repos)} GitLab repos")
235+
236+
print("Fetching GitHub repos...")
237+
github_repos = get_github_repos(github_token)
238+
print(f" Found {len(github_repos)} GitHub repos")
239+
240+
print("Reconciling...")
241+
result = reconcile(gitlab_repos, github_repos)
242+
243+
print_report(result)
244+
245+
# Save full report as JSON
246+
with open("reconciliation-report.json", "w") as f:
247+
json.dump(result, f, indent=2, default=str)
248+
print("\nFull report saved to reconciliation-report.json")
249+
250+
if __name__ == "__main__":
251+
main()
252+
253+
254+
# Quick check mode - just test a list of repos
255+
def quick_check_github(repos: list[str]) -> dict:
256+
"""Check if repos exist on GitHub without auth."""
257+
import urllib.request
258+
results = {"exists": [], "missing": []}
259+
for repo in repos:
260+
url = f"https://github.com/hyperpolymath/{repo}"
261+
try:
262+
req = urllib.request.Request(url, method='HEAD')
263+
urllib.request.urlopen(req, timeout=5)
264+
results["exists"].append(repo)
265+
except:
266+
results["missing"].append(repo)
267+
return results
268+
269+
if __name__ == "__main__" and len(__import__('sys').argv) > 1:
270+
# Quick mode: python repo-reconcile.py check repo1 repo2 repo3
271+
if __import__('sys').argv[1] == "check":
272+
repos = __import__('sys').argv[2:]
273+
print(f"Checking {len(repos)} repos against GitHub...")
274+
results = quick_check_github(repos)
275+
print(f"\n✓ EXISTS on GitHub ({len(results['exists'])}):")
276+
for r in results['exists']:
277+
print(f" {r}")
278+
print(f"\n✗ MISSING on GitHub ({len(results['missing'])}):")
279+
for r in results['missing']:
280+
print(f" {r}")

0 commit comments

Comments
 (0)