Created
March 18, 2026 07:05
-
-
Save flodolo/aee98009c03a8987283dc82cd5d47ba2 to your computer and use it in GitHub Desktop.
Check access to repositories within GitHub organization
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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