Skip to content

Instantly share code, notes, and snippets.

@flodolo
Created March 18, 2026 07:05
Show Gist options
  • Select an option

  • Save flodolo/aee98009c03a8987283dc82cd5d47ba2 to your computer and use it in GitHub Desktop.

Select an option

Save flodolo/aee98009c03a8987283dc82cd5d47ba2 to your computer and use it in GitHub Desktop.
Check access to repositories within GitHub organization
#!/usr/bin/env python3
"""
GitHub Org Repository Access Report
Checks your access level to all public, active repositories in a GitHub
organization and prints a grouped report, distinguishing direct vs.
team-inherited permissions.
Usage:
python github_access_report.py --org <org> --token <token>
"""
import argparse
import sys
from collections import defaultdict
try:
import requests
except ImportError:
sys.exit("Missing dependency: pip install requests")
BASE_URL = "https://api.github.com"
PERMISSION_ORDER = ["admin", "maintain", "write", "triage", "read", "none", "unknown"]
def make_headers(token):
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def paginate(url, hdrs, params=None):
"""Fetch all pages from a GitHub list endpoint."""
items = []
page_params = {**(params or {}), "per_page": 100, "page": 1}
while True:
resp = requests.get(url, headers=hdrs, params=page_params)
resp.raise_for_status()
data = resp.json()
if not data:
break
items.extend(data)
if len(data) < 100:
break
page_params["page"] += 1
return items
def get_current_user(hdrs):
resp = requests.get(f"{BASE_URL}/user", headers=hdrs)
resp.raise_for_status()
return resp.json()["login"]
def get_org_role(org, username, hdrs):
"""Returns 'admin', 'member', or None if not a member."""
resp = requests.get(f"{BASE_URL}/orgs/{org}/memberships/{username}", headers=hdrs)
if resp.status_code == 200:
return resp.json().get("role")
return None
def get_user_teams_in_org(org, hdrs):
"""Returns {slug: name} for all teams the authenticated user belongs to in org."""
all_teams = paginate(f"{BASE_URL}/user/teams", hdrs)
return {
t["slug"]: t["name"]
for t in all_teams
if t["organization"]["login"].lower() == org.lower()
}
def get_repos(org, hdrs):
"""Returns all public, non-archived repos in the org."""
repos = paginate(f"{BASE_URL}/orgs/{org}/repos", hdrs, {"type": "public"})
return [r for r in repos if not r["archived"]]
def get_permission(org, repo_name, username, hdrs):
"""
Returns the permission string for username on repo_name, or None if not
a collaborator (404).
"""
url = f"{BASE_URL}/repos/{org}/{repo_name}/collaborators/{username}/permission"
resp = requests.get(url, headers=hdrs)
if resp.status_code == 404:
return None
if resp.status_code != 200:
return f"error:{resp.status_code}"
return resp.json().get("permission", "none")
def get_direct_collaborators(org, repo_name, hdrs):
"""Returns a set of lowercase logins added directly (not via team)."""
url = f"{BASE_URL}/repos/{org}/{repo_name}/collaborators"
resp = requests.get(url, headers=hdrs, params={"affiliation": "direct", "per_page": 100})
if resp.status_code != 200:
return None # insufficient permissions to query
logins = set()
page = 1
data = resp.json()
while data:
logins.update(c["login"].lower() for c in data)
if len(data) < 100:
break
page += 1
resp = requests.get(url, headers=hdrs, params={"affiliation": "direct", "per_page": 100, "page": page})
if resp.status_code != 200:
break
data = resp.json()
return logins
def get_repo_teams(org, repo_name, hdrs):
"""Returns a list of team dicts for teams that have access to the repo."""
url = f"{BASE_URL}/repos/{org}/{repo_name}/teams"
resp = requests.get(url, headers=hdrs)
if resp.status_code != 200:
return []
return resp.json()
def determine_source(org, repo_name, username, permission, is_owner, user_teams, hdrs):
"""Determine whether the permission is direct, via team(s), or inherited."""
if is_owner:
return "org owner (admin on all repos)"
sources = []
# Check direct collaborators
direct_logins = get_direct_collaborators(org, repo_name, hdrs)
if direct_logins is None:
# Couldn't query — fall back to unknown source
sources.append("direct (unverified)")
elif username.lower() in direct_logins:
sources.append("direct")
# Check team access
if user_teams:
repo_teams = get_repo_teams(org, repo_name, hdrs)
repo_team_slugs = {t["slug"] for t in repo_teams}
matching = sorted(
user_teams[slug]
for slug in repo_team_slugs
if slug in user_teams
)
if matching:
sources.append(f"team{'s' if len(matching) > 1 else ''}: {', '.join(matching)}")
if not sources:
return "org base permissions"
return " + ".join(sources)
def print_separator(char="=", width=72):
print(char * width)
def main():
parser = argparse.ArgumentParser(
description="Report your access level to all public repos in a GitHub org."
)
parser.add_argument("--org", required=True, help="GitHub organization login")
parser.add_argument("--token", required=True, help="GitHub personal access token")
args = parser.parse_args()
hdrs = make_headers(args.token)
# --- Setup ---
print("Authenticating...")
try:
username = get_current_user(hdrs)
except requests.HTTPError as e:
sys.exit(f"Authentication failed: {e}")
print(f" Authenticated as: {username}")
org_role = get_org_role(args.org, username, hdrs)
is_owner = org_role == "admin"
if org_role:
print(f" Org role in '{args.org}': {org_role}")
else:
print(f" Not a member of '{args.org}' (or insufficient permissions to check)")
print("Fetching team memberships...")
user_teams = get_user_teams_in_org(args.org, hdrs)
if user_teams:
print(f" Member of {len(user_teams)} team(s): {', '.join(sorted(user_teams.values()))}")
else:
print(" Not a member of any teams in this org")
print(f"Fetching repositories for '{args.org}'...")
try:
repos = get_repos(args.org, hdrs)
except requests.HTTPError as e:
sys.exit(f"Failed to fetch repositories: {e}")
print(f" Found {len(repos)} public, active repositories\n")
# --- Per-repo access check ---
# Results: permission -> list of (repo_name, source)
results = defaultdict(list)
skipped = []
total = len(repos)
for i, repo in enumerate(repos, 1):
name = repo["name"]
print(f" [{i:>{len(str(total))}}/{total}] {name}", end="\r", flush=True)
permission = get_permission(args.org, name, username, hdrs)
if permission is None:
# 404 → not a collaborator; still record with "none"
results["none"].append((name, "not a collaborator"))
elif permission.startswith("error:"):
skipped.append((name, permission))
else:
source = determine_source(
args.org, name, username, permission, is_owner, user_teams, hdrs
)
results[permission].append((name, source))
print(" " * 80, end="\r") # clear progress line
# --- Report ---
print_separator()
print(f" ACCESS REPORT — {username} @ {args.org}")
print_separator()
has_results = any(
perm in results and perm != "none"
for perm in PERMISSION_ORDER
)
for perm in PERMISSION_ORDER:
if perm == "none" or perm not in results:
continue
entries = sorted(results[perm])
label = perm.upper()
print(f"\n [{label}] ({len(entries)} {'repo' if len(entries) == 1 else 'repos'})")
print_separator("-")
for repo_name, source in entries:
print(f" {repo_name:<45} {source}")
# Show repos with no access only as a count to avoid noise
none_count = len(results.get("none", []))
if none_count:
print(f"\n [NONE] ({none_count} repos — no explicit collaborator access)")
if skipped:
print(f"\n [SKIPPED — API errors]")
print_separator("-")
for repo_name, err in sorted(skipped):
print(f" {repo_name:<45} {err}")
# Summary
print()
print_separator()
print(" SUMMARY")
print_separator("-")
for perm in PERMISSION_ORDER:
if perm in results:
count = len(results[perm])
marker = " *" if perm not in ("none",) and count > 0 else ""
print(f" {perm:<10}: {count:>4} repo(s){marker}")
print(f" {'total':<10}: {total:>4} repos checked")
if skipped:
print(f" {'skipped':<10}: {len(skipped):>4} (API errors)")
print_separator()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment