Skip to content

Commit efbb713

Browse files
hyperpolymathclaude
andcommitted
feat: migrate repo-reconcile from Python to Julia
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3c4570e commit efbb713

2 files changed

Lines changed: 351 additions & 280 deletions

File tree

scripts/repo-reconcile.jl

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
#!/usr/bin/env julia
2+
# SPDX-License-Identifier: PMPL-1.0-or-later
3+
#
4+
# Repo Reconciliation Tool
5+
# Compares GitLab and GitHub repositories to identify sync gaps.
6+
#
7+
# Usage:
8+
# export GITLAB_TOKEN="your-token"
9+
# export GITHUB_TOKEN="your-token"
10+
# julia repo-reconcile.jl
11+
#
12+
# Quick check mode (no auth needed):
13+
# julia repo-reconcile.jl check repo1 repo2 repo3
14+
#
15+
# Outputs a reconciliation report showing:
16+
# - Repos only on GitLab (need to mirror to GitHub)
17+
# - Repos only on GitHub (new creations or need to mirror to GitLab)
18+
# - Repos on both (check if synced or diverged)
19+
20+
using JSON3
21+
using Dates
22+
using HTTP # HTTP.jl for REST calls
23+
24+
# ---------------------------------------------------------------------------
25+
# Configuration
26+
# ---------------------------------------------------------------------------
27+
28+
const GITLAB_USER = "hyperpolymath"
29+
const GITLAB_GROUPS = ["maa-framework"] # Add more groups as needed
30+
const GITHUB_USER = "hyperpolymath"
31+
32+
# ---------------------------------------------------------------------------
33+
# Data types
34+
# ---------------------------------------------------------------------------
35+
36+
"""A repository entry from either GitLab or GitHub."""
37+
struct Repo
38+
name::String
39+
platform::String
40+
url::String
41+
last_activity::Union{String, Nothing}
42+
commit_count::Union{Int, Nothing}
43+
description::Union{String, Nothing}
44+
end
45+
46+
# Convenience constructor (mirrors the Python dataclass defaults).
47+
Repo(name, platform, url, last_activity;
48+
commit_count=nothing, description=nothing) =
49+
Repo(name, platform, url, last_activity, commit_count, description)
50+
51+
# ---------------------------------------------------------------------------
52+
# GitLab helpers
53+
# ---------------------------------------------------------------------------
54+
55+
"""Fetch all repos from GitLab user and groups, paginated."""
56+
function get_gitlab_repos(token::String)::Vector{Repo}
57+
repos = Repo[]
58+
headers = ["PRIVATE-TOKEN" => token]
59+
60+
# --- User repos ---
61+
page = 1
62+
while true
63+
url = "https://gitlab.com/api/v4/users/$(GITLAB_USER)/projects?per_page=100&page=$(page)"
64+
resp = HTTP.get(url, headers; status_exception=false)
65+
if resp.status != 200
66+
println("GitLab API error: $(resp.status)")
67+
break
68+
end
69+
data = JSON3.read(resp.body)
70+
isempty(data) && break
71+
for r in data
72+
push!(repos, Repo(
73+
r[:path],
74+
"gitlab",
75+
r[:web_url],
76+
get(r, :last_activity_at, nothing);
77+
description = get(r, :description, nothing)
78+
))
79+
end
80+
page += 1
81+
end
82+
83+
# --- Group repos ---
84+
for group in GITLAB_GROUPS
85+
page = 1
86+
while true
87+
url = "https://gitlab.com/api/v4/groups/$(group)/projects?per_page=100&page=$(page)&include_subgroups=true"
88+
resp = HTTP.get(url, headers; status_exception=false)
89+
resp.status != 200 && break
90+
data = JSON3.read(resp.body)
91+
isempty(data) && break
92+
for r in data
93+
push!(repos, Repo(
94+
r[:path],
95+
"gitlab",
96+
r[:web_url],
97+
get(r, :last_activity_at, nothing);
98+
description = get(r, :description, nothing)
99+
))
100+
end
101+
page += 1
102+
end
103+
end
104+
105+
return repos
106+
end
107+
108+
# ---------------------------------------------------------------------------
109+
# GitHub helpers
110+
# ---------------------------------------------------------------------------
111+
112+
"""Fetch all repos from GitHub user, paginated."""
113+
function get_github_repos(token::String)::Vector{Repo}
114+
repos = Repo[]
115+
headers = ["Authorization" => "token $(token)"]
116+
117+
page = 1
118+
while true
119+
url = "https://api.github.com/users/$(GITHUB_USER)/repos?per_page=100&page=$(page)"
120+
resp = HTTP.get(url, headers; status_exception=false)
121+
if resp.status != 200
122+
println("GitHub API error: $(resp.status)")
123+
break
124+
end
125+
data = JSON3.read(resp.body)
126+
isempty(data) && break
127+
for r in data
128+
push!(repos, Repo(
129+
r[:name],
130+
"github",
131+
r[:html_url],
132+
get(r, :pushed_at, nothing);
133+
description = get(r, :description, nothing)
134+
))
135+
end
136+
page += 1
137+
end
138+
139+
return repos
140+
end
141+
142+
# ---------------------------------------------------------------------------
143+
# Reconciliation logic
144+
# ---------------------------------------------------------------------------
145+
146+
"""Compare repos and categorise sync status. Returns a Dict of categories."""
147+
function reconcile(gitlab_repos::Vector{Repo},
148+
github_repos::Vector{Repo})::Dict{String, Vector}
149+
gitlab_names = Dict(r.name => r for r in gitlab_repos)
150+
github_names = Dict(r.name => r for r in github_repos)
151+
152+
all_names = union(keys(gitlab_names), keys(github_names))
153+
154+
result = Dict{String, Vector}(
155+
"gitlab_only" => [],
156+
"github_only" => [],
157+
"both_synced" => [],
158+
"both_diverged" => [],
159+
"unknown" => []
160+
)
161+
162+
for name in sort(collect(all_names))
163+
gl = get(gitlab_names, name, nothing)
164+
gh = get(github_names, name, nothing)
165+
166+
if !isnothing(gl) && isnothing(gh)
167+
push!(result["gitlab_only"], Dict(
168+
"name" => name,
169+
"gitlab_url" => gl.url,
170+
"last_activity" => gl.last_activity,
171+
"action" => "Mirror to GitHub"
172+
))
173+
elseif !isnothing(gh) && isnothing(gl)
174+
push!(result["github_only"], Dict(
175+
"name" => name,
176+
"github_url" => gh.url,
177+
"last_activity" => gh.last_activity,
178+
"action" => "New on GitHub (mirror to GitLab?)"
179+
))
180+
else
181+
# Both exist — check if diverged
182+
gl_time = something(gl.last_activity, "")
183+
gh_time = something(gh.last_activity, "")
184+
185+
try
186+
# Parse ISO-8601 timestamps (replace trailing Z with +00:00)
187+
gl_dt = DateTime(replace(gl_time, "Z" => "+00:00")[1:min(end, 19)],
188+
dateformat"yyyy-mm-ddTHH:MM:SS")
189+
gh_dt = DateTime(replace(gh_time, "Z" => "+00:00")[1:min(end, 19)],
190+
dateformat"yyyy-mm-ddTHH:MM:SS")
191+
diff_days = abs(Dates.value(Day(gl_dt - gh_dt)))
192+
193+
if diff_days > 1
194+
ahead = gl_dt > gh_dt ? "GitLab" : "GitHub"
195+
push!(result["both_diverged"], Dict(
196+
"name" => name,
197+
"gitlab_url" => gl.url,
198+
"github_url" => gh.url,
199+
"gitlab_activity" => gl_time,
200+
"github_activity" => gh_time,
201+
"ahead" => ahead,
202+
"days_apart" => diff_days,
203+
"action" => "Check sync - $(ahead) is $(diff_days) days ahead"
204+
))
205+
else
206+
push!(result["both_synced"], Dict(
207+
"name" => name,
208+
"status" => "Likely synced"
209+
))
210+
end
211+
catch
212+
push!(result["unknown"], Dict(
213+
"name" => name,
214+
"gitlab_url" => isnothing(gl) ? nothing : gl.url,
215+
"github_url" => isnothing(gh) ? nothing : gh.url,
216+
"action" => "Could not compare dates"
217+
))
218+
end
219+
end
220+
end
221+
222+
return result
223+
end
224+
225+
# ---------------------------------------------------------------------------
226+
# Report printer
227+
# ---------------------------------------------------------------------------
228+
229+
"""Print human-readable reconciliation report."""
230+
function print_report(result::Dict{String, Vector})
231+
println()
232+
println("=" ^ 70)
233+
println("REPOSITORY RECONCILIATION REPORT")
234+
println("=" ^ 70)
235+
236+
n_gl = length(result["gitlab_only"])
237+
println("\n## GITLAB ONLY ($(n_gl) repos)")
238+
println("These repos exist on GitLab but NOT on GitHub:")
239+
for r in result["gitlab_only"][1:min(end, 20)]
240+
println(" - $(r["name"]): $(r["gitlab_url"])")
241+
end
242+
n_gl > 20 && println(" ... and $(n_gl - 20) more")
243+
244+
n_gh = length(result["github_only"])
245+
println("\n## GITHUB ONLY ($(n_gh) repos)")
246+
println("These repos exist on GitHub but NOT on GitLab:")
247+
for r in result["github_only"][1:min(end, 20)]
248+
println(" - $(r["name"]): $(r["github_url"])")
249+
end
250+
n_gh > 20 && println(" ... and $(n_gh - 20) more")
251+
252+
n_div = length(result["both_diverged"])
253+
println("\n## POTENTIALLY DIVERGED ($(n_div) repos)")
254+
println("These repos exist on both but may be out of sync:")
255+
for r in result["both_diverged"]
256+
println(" - $(r["name"]): $(r["ahead"]) ahead by $(r["days_apart"]) days")
257+
end
258+
259+
n_sync = length(result["both_synced"])
260+
println("\n## LIKELY SYNCED ($(n_sync) repos)")
261+
262+
println()
263+
println("=" ^ 70)
264+
println("SUMMARY")
265+
println("=" ^ 70)
266+
# Right-align counts to 4 chars, matching the Python format
267+
println(" GitLab only: $(lpad(string(n_gl), 4)) (need to mirror to GitHub)")
268+
println(" GitHub only: $(lpad(string(n_gh), 4)) (new on GitHub)")
269+
println(" Diverged: $(lpad(string(n_div), 4)) (need sync check)")
270+
println(" Synced: $(lpad(string(n_sync), 4)) (OK)")
271+
println(" Unknown: $(lpad(string(length(result["unknown"])), 4))")
272+
end
273+
274+
# ---------------------------------------------------------------------------
275+
# Quick check mode (no auth required)
276+
# ---------------------------------------------------------------------------
277+
278+
"""Check if repos exist on GitHub without authentication (HEAD requests)."""
279+
function quick_check_github(repos::Vector{String})::Dict{String, Vector{String}}
280+
results = Dict("exists" => String[], "missing" => String[])
281+
for repo in repos
282+
url = "https://github.com/hyperpolymath/$(repo)"
283+
try
284+
resp = HTTP.head(url; status_exception=false, connect_timeout=5,
285+
readtimeout=5)
286+
if resp.status < 400
287+
push!(results["exists"], repo)
288+
else
289+
push!(results["missing"], repo)
290+
end
291+
catch
292+
push!(results["missing"], repo)
293+
end
294+
end
295+
return results
296+
end
297+
298+
# ---------------------------------------------------------------------------
299+
# Entry point
300+
# ---------------------------------------------------------------------------
301+
302+
function main()
303+
# Quick-check mode: julia repo-reconcile.jl check repo1 repo2 ...
304+
if length(ARGS) >= 2 && ARGS[1] == "check"
305+
repos = ARGS[2:end]
306+
println("Checking $(length(repos)) repos against GitHub...")
307+
results = quick_check_github(repos)
308+
println("\n✓ EXISTS on GitHub ($(length(results["exists"]))):")
309+
for r in results["exists"]
310+
println(" $(r)")
311+
end
312+
println("\n✗ MISSING on GitHub ($(length(results["missing"]))):")
313+
for r in results["missing"]
314+
println(" $(r)")
315+
end
316+
return
317+
end
318+
319+
# Full reconciliation mode
320+
gitlab_token = get(ENV, "GITLAB_TOKEN", "")
321+
github_token = get(ENV, "GITHUB_TOKEN", "")
322+
323+
if isempty(gitlab_token) || isempty(github_token)
324+
println("Please set GITLAB_TOKEN and GITHUB_TOKEN environment variables")
325+
println("\nTo get tokens:")
326+
println(" GitLab: Settings > Access Tokens > Create with 'read_api' scope")
327+
println(" GitHub: Settings > Developer settings > Personal access tokens")
328+
return
329+
end
330+
331+
println("Fetching GitLab repos...")
332+
gitlab_repos = get_gitlab_repos(gitlab_token)
333+
println(" Found $(length(gitlab_repos)) GitLab repos")
334+
335+
println("Fetching GitHub repos...")
336+
github_repos = get_github_repos(github_token)
337+
println(" Found $(length(github_repos)) GitHub repos")
338+
339+
println("Reconciling...")
340+
result = reconcile(gitlab_repos, github_repos)
341+
342+
print_report(result)
343+
344+
# Save full report as JSON
345+
open("reconciliation-report.json", "w") do f
346+
JSON3.pretty(f, result)
347+
end
348+
println("\nFull report saved to reconciliation-report.json")
349+
end
350+
351+
main()

0 commit comments

Comments
 (0)