Bulk GitHub Repo Changes Across an Org¶
Platform engineering has to touch every repo in an org: add a
CODEOWNERS file, enable branch protection, bump a shared workflow.
Do it with a naive ThreadPoolExecutor and you hit the wall GitHub
built for exactly this — secondary rate limits (abuse detection) that
return 403/429 with a Retry-After header when you burst mutations.
Worse, this is a write fan-out across hundreds of repos: if it starts
going wrong, you want to stop, not power through 500 half-applied
changes.
import time
from github import Github, GithubException
from pyarallel import parallel_map, Limiter, RateLimit, Retry
gh = Github(TOKEN)
def add_codeowners(repo_full_name):
repo = gh.get_repo(repo_full_name)
repo.create_file(
".github/CODEOWNERS",
"Add CODEOWNERS",
"* @my-org/platform-team\n",
branch=repo.default_branch,
)
return repo_full_name
def is_rate_limit(exc):
# Retry ONLY rate limits — never a plain permission/scope 403, which
# will fail identically on every retry. A rate-limit 403/429 carries
# a Retry-After header, an exhausted x-ratelimit-remaining, or an
# "abuse"/"secondary rate limit" message; a bad-scope 403 carries none.
if not isinstance(exc, GithubException) or exc.status not in (403, 429):
return False
headers = getattr(exc, "headers", None) or {}
if headers.get("retry-after") or headers.get("x-ratelimit-remaining") == "0":
return True
message = str(getattr(exc, "data", "")).lower()
return "secondary rate limit" in message or "abuse" in message
def github_wait(exc):
headers = getattr(exc, "headers", None) or {}
if headers.get("retry-after"): # secondary limit, told when
return float(headers["retry-after"])
if headers.get("x-ratelimit-remaining") == "0" and headers.get("x-ratelimit-reset"):
return max(0.0, float(headers["x-ratelimit-reset"]) - time.time()) # primary: wait for reset
return 60.0 # secondary, no header: GitHub says wait >= 60s
repos = [r.full_name for r in gh.get_organization("my-org").get_repos()]
# The token's 5,000 req/hr budget — shared across every job that uses it.
budget = Limiter(RateLimit(5000, "hour"))
result = parallel_map(
add_codeowners, repos,
workers=8,
rate_limit=budget,
retry=Retry(
attempts=4,
backoff=2.0,
retry_if=is_rate_limit,
wait_from=github_wait, # one worker's throttle wait pauses the whole pool
),
# Why not Retry.for_http()? GitHub signals rate limits as 403 WITH
# rate-limit headers — but plain 403 is also "no permission", which
# must NOT be retried. Status alone can't tell them apart, so this
# recipe keeps its header-aware predicate. for_http covers APIs
# where status is the whole signal.
checkpoint="codeowners.ckpt",
checkpoint_key=lambda name: name, # resume by repo — never re-mutate a done one
max_errors=25, # the kill-switch: abort the migration if it goes wrong
)
for idx, name in result.successes():
print(f"done: {name}")
for idx, exc in result.failures():
print(f"needs attention: {repos[idx]} — {exc}")
Why each policy earns its place:
wait_from=github_wait— GitHub's docs are specific: honorRetry-Afterwhen present, wait untilx-ratelimit-resetwhen the primary budget is exhausted, and back off at least 60s on a secondary limit with no header.retry_ifdeliberately does not retry a plain permission 403 — that would fail identically on every attempt. Pyarallel applies the wait pool-wide: one worker's throttle signal pauses the shared limiter, so all eight back off together instead of each discovering the block separately and making abuse detection angrier.checkpoint_key=lambda name: name— a mutation must never run twice. Keyed by repo name, a resumed run skips every repo already changed, even if the input list was reordered.max_errors=25— the feature that turns a dangerous bulk write into a safe one. If changes start failing (a bad token scope, an API incident), the run stops admitting work and hands back partial results instead of grinding a broken change across the whole org.result.failures()— yields(index, exception)pairs; index back intoreposfor the exact repos left untouched, typed and ready to feed into a narrower rerun.
This pattern generalizes to any GitHub bulk operation — opening PRs across
repos (the classic abuse-detection trigger), updating topics, syncing
labels — and to any API with Retry-After secondary limits.