#!/usr/bin/env -S uv --quiet run --script # /// script # requires-python = ">=3.12" # dependencies = [ # "requests", # ] # /// import sys import requests from pathlib import Path import argparse import logging import csv from io import StringIO logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) class GitHubRepoChecker: def __init__(self, token, org_name): """ Initialize the GitHub repository checker. Args: token (str): GitHub personal access token org_name (str): Name of the GitHub organization """ self.token = token self.org_name = org_name self.headers = { "Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json", } self.api_base = "https://api.github.com" def get_repositories(self): """Fetch all repositories for the organization.""" repos = [] page = 1 while True: url = f"{self.api_base}/orgs/{self.org_name}/repos" params = {"page": page, "per_page": 100} response = requests.get(url, headers=self.headers, params=params) response.raise_for_status() page_repos = response.json() if not page_repos: break repos.extend(page_repos) page += 1 return repos def check_branch_protection(self, repo_name, default_branch): """ Check if 'Require approval of the most recent reviewable push' is enabled. Args: repo_name (str): Name of the repository default_branch (str): Default branch name Returns: tuple: (status, error_message, rule_id) where status is True/False/None, error_message is str or None, and rule_id is str or None """ url = f"{self.api_base}/repos/{self.org_name}/{repo_name}/branches/{default_branch}/protection" try: response = requests.get(url, headers=self.headers) if response.status_code == 404: # Branch protection not configured return False, "No branch protection configured", None elif response.status_code == 403: # Likely a private repo or insufficient permissions return None, "Access denied", None response.raise_for_status() protection_data = response.json() # Check if required_pull_request_reviews exists and dismiss_stale_reviews is True pr_reviews = protection_data.get("required_pull_request_reviews", {}) dismiss_stale = pr_reviews.get("dismiss_stale_reviews", False) # Get the rule ID for URL construction rule_id = protection_data.get("id") return dismiss_stale, None, rule_id except requests.exceptions.RequestException as e: return None, f"API error: {str(e)}", None def get_branch_protection_url(self, repo_name, rule_id): """Generate the GitHub web UI URL for branch protection settings.""" if rule_id: return f"https://github.com/{self.org_name}/{repo_name}/settings/branch_protection_rules/{rule_id}" else: # Fallback to general branch protection settings if no rule ID return f"https://github.com/{self.org_name}/{repo_name}/settings/branches" def check_all_repositories(self, output_file=None): """Check branch protection for all repositories and output to CSV.""" try: repos = self.get_repositories() logger.info(f"Found {len(repos)} repositories in {self.org_name}") # Prepare CSV data csv_data = [] results = [] for repo in repos: repo_name = repo["name"] default_branch = repo["default_branch"] is_archived = repo["archived"] logger.info(f"Checking {repo_name}...") status, error_msg, rule_id = self.check_branch_protection( repo_name, default_branch ) # Convert status to human-readable string if status is True: status_text = "Enabled" elif status is False: status_text = "Disabled" else: # status is None status_text = "Unknown" notes = error_msg or "" protection_url = self.get_branch_protection_url(repo_name, rule_id) csv_data.append( { "Repository": repo_name, "Default_Branch": default_branch, "Archived": is_archived, "Dismiss_Stale_Reviews": status_text, "Notes": notes, "Branch_Protection_URL": protection_url, } ) results.append((repo_name, status, error_msg)) # Generate CSV output output = StringIO() fieldnames = [ "Repository", "Default_Branch", "Archived", "Dismiss_Stale_Reviews", "Notes", "Branch_Protection_URL", ] writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() writer.writerows(csv_data) csv_content = output.getvalue() output.close() # Write to file or print to stdout if output_file: with open(output_file, "w", newline="") as f: f.write(csv_content) logger.info(f"CSV output written to {output_file}") else: print(csv_content) # Summary statistics to stderr so it doesn't interfere with CSV output enabled_count = sum(1 for _, status, _ in results if status is True) disabled_count = sum(1 for _, status, _ in results if status is False) unknown_count = sum(1 for _, status, _ in results if status is None) archived_count = sum(1 for row in csv_data if row["Archived"] is True) logger.info( f"Summary - Enabled: {enabled_count}, Disabled: {disabled_count}, Unknown/Error: {unknown_count}, Archived: {archived_count}, Total: {len(results)}" ) except Exception as e: logger.error(f"Failed to fetch repositories: {e}") sys.exit(1) def main(): parser = argparse.ArgumentParser( description="Check GitHub repository branch protection settings" ) parser.add_argument("--token", required=True, help="GitHub personal access token") parser.add_argument("--org", required=True, help="GitHub organization name") parser.add_argument( "--output", help="Output CSV file path (if not specified, prints to stdout)" ) args = parser.parse_args() checker = GitHubRepoChecker(args.token, args.org) checker.check_all_repositories(args.output) if __name__ == "__main__": main()