#!/usr/bin/env python3 """ Apply branch protection rules to all Gitea repositories. This script uses the Gitea API to apply consistent branch protection rules across all repositories owned by a user or organization. """ import os import sys import requests from typing import Optional from dataclasses import dataclass @dataclass class BranchProtectionConfig: """Configuration for branch protection rules.""" # Branch to protect branch_name: str = "main" # Push settings enable_push: bool = False # Disable direct pushes (force PR workflow) enable_push_whitelist: bool = False push_whitelist_usernames: list[str] = None # Pull request approval settings required_approvals: int = 1 enable_approvals_whitelist: bool = True approvals_whitelist_usernames: list[str] = None dismiss_stale_approvals: bool = True # Status check settings enable_status_check: bool = True status_check_contexts: list[str] = None # Empty for now, add when CI/CD is set up # Merge settings enable_merge_whitelist: bool = True merge_whitelist_usernames: list[str] = None block_on_rejected_reviews: bool = True block_on_outdated_branch: bool = True # Critical for database migrations # Other settings require_signed_commits: bool = False protected_file_patterns: str = "" unprotected_file_patterns: str = "" def __post_init__(self): """Initialize mutable default values.""" if self.push_whitelist_usernames is None: self.push_whitelist_usernames = [] if self.approvals_whitelist_usernames is None: self.approvals_whitelist_usernames = [] if self.merge_whitelist_usernames is None: self.merge_whitelist_usernames = [] if self.status_check_contexts is None: self.status_check_contexts = [] class GiteaBranchProtectionManager: """Manages branch protection rules across Gitea repositories.""" def __init__(self, gitea_url: str, api_token: str, owner: str): """ Initialize the manager. Args: gitea_url: Base URL of your Gitea instance (e.g., https://git.example.com) api_token: Gitea API token with repo access owner: Username or organization name """ self.gitea_url = gitea_url.rstrip('/') self.api_token = api_token self.owner = owner self.headers = { 'Authorization': f'token {api_token}', 'Content-Type': 'application/json' } def get_repositories(self) -> list[dict]: """Get all repositories for the authenticated user.""" # Use /user/repos for authenticated requests (works with API token) url = f'{self.gitea_url}/api/v1/user/repos' params = {'limit': 100} # Adjust if you have more than 100 repos try: response = requests.get(url, headers=self.headers, params=params) response.raise_for_status() repos = response.json() # Filter to only repos owned by the specified owner if needed if self.owner: repos = [r for r in repos if r.get('owner', {}).get('login') == self.owner] return repos except requests.exceptions.RequestException as e: print(f"❌ Error fetching repositories: {e}") sys.exit(1) def get_existing_branch_protections(self, repo_name: str) -> list[dict]: """Get existing branch protections for a repository.""" url = f'{self.gitea_url}/api/v1/repos/{self.owner}/{repo_name}/branch_protections' try: response = requests.get(url, headers=self.headers) response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: print(f" ⚠️ Could not fetch existing protections: {e}") return [] def delete_branch_protection(self, repo_name: str, protection_name: str) -> bool: """Delete an existing branch protection rule.""" url = f'{self.gitea_url}/api/v1/repos/{self.owner}/{repo_name}/branch_protections/{protection_name}' try: response = requests.delete(url, headers=self.headers) response.raise_for_status() return True except requests.exceptions.RequestException as e: print(f" ⚠️ Could not delete existing protection: {e}") return False def apply_branch_protection(self, repo_name: str, config: BranchProtectionConfig) -> bool: """ Apply branch protection rules to a repository. Args: repo_name: Name of the repository config: Branch protection configuration Returns: True if successful, False otherwise """ url = f'{self.gitea_url}/api/v1/repos/{self.owner}/{repo_name}/branch_protections' payload = { 'branch_name': config.branch_name, 'enable_push': config.enable_push, 'enable_push_whitelist': config.enable_push_whitelist, 'push_whitelist_usernames': config.push_whitelist_usernames, 'required_approvals': config.required_approvals, 'enable_approvals_whitelist': config.enable_approvals_whitelist, 'approvals_whitelist_usernames': config.approvals_whitelist_usernames, 'dismiss_stale_approvals': config.dismiss_stale_approvals, 'enable_status_check': config.enable_status_check, 'status_check_contexts': config.status_check_contexts, 'enable_merge_whitelist': config.enable_merge_whitelist, 'merge_whitelist_usernames': config.merge_whitelist_usernames, 'block_on_rejected_reviews': config.block_on_rejected_reviews, 'block_on_outdated_branch': config.block_on_outdated_branch, 'require_signed_commits': config.require_signed_commits, 'protected_file_patterns': config.protected_file_patterns, 'unprotected_file_patterns': config.unprotected_file_patterns, } try: response = requests.post(url, headers=self.headers, json=payload) response.raise_for_status() return True except requests.exceptions.RequestException as e: print(f" ❌ Error: {e}") if hasattr(e.response, 'text'): print(f" Response: {e.response.text}") return False def apply_to_all_repos(self, config: BranchProtectionConfig, dry_run: bool = False): """ Apply branch protection rules to all repositories. Args: config: Branch protection configuration dry_run: If True, only show what would be done without making changes """ repos = self.get_repositories() if not repos: print("No repositories found.") return print(f"\n{'DRY RUN - ' if dry_run else ''}Applying branch protection to {len(repos)} repositories...\n") success_count = 0 skip_count = 0 error_count = 0 for repo in repos: repo_name = repo['name'] print(f"📦 {repo_name}") # Check if branch exists default_branch = repo.get('default_branch', 'main') if config.branch_name != default_branch: print(f" ℹ️ Default branch is '{default_branch}', protecting '{config.branch_name}' instead") if dry_run: print(f" ✓ Would apply protection to '{config.branch_name}' branch") skip_count += 1 continue # Check for existing protection existing_protections = self.get_existing_branch_protections(repo_name) existing_main_protection = None for protection in existing_protections: if protection.get('branch_name') == config.branch_name: existing_main_protection = protection.get('id') or protection.get('branch_name') break # Delete existing protection if it exists if existing_main_protection: print(f" 🔄 Updating existing protection...") self.delete_branch_protection(repo_name, existing_main_protection) # Apply new protection if self.apply_branch_protection(repo_name, config): print(f" ✅ Successfully applied branch protection") success_count += 1 else: print(f" ❌ Failed to apply branch protection") error_count += 1 print(f"\n{'=' * 60}") print(f"Summary:") print(f" ✅ Successful: {success_count}") if dry_run: print(f" ⏭️ Skipped (dry run): {skip_count}") if error_count > 0: print(f" ❌ Errors: {error_count}") print(f"{'=' * 60}\n") def main(): """Main entry point.""" # Configuration - adjust these values or use environment variables GITEA_URL = os.getenv('GITEA_URL', 'https://git.manticorum.com') GITEA_TOKEN = os.getenv('GITEA_TOKEN') GITEA_OWNER = os.getenv('GITEA_OWNER', 'cal') # Check for required configuration if not GITEA_TOKEN: print("❌ Error: GITEA_TOKEN environment variable is required") print("\nUsage:") print(" export GITEA_TOKEN='your-api-token-here'") print(" python apply_branch_protection.py [--dry-run]") print("\nOptional environment variables:") print(" GITEA_URL (default: https://git.manticorum.com)") print(" GITEA_OWNER (default: cal)") sys.exit(1) # Parse command line arguments dry_run = '--dry-run' in sys.argv # Configure branch protection rules # Adjust these settings as needed config = BranchProtectionConfig( branch_name="main", enable_push=False, # Disable direct pushes required_approvals=1, enable_approvals_whitelist=True, approvals_whitelist_usernames=[GITEA_OWNER], dismiss_stale_approvals=True, enable_status_check=True, enable_merge_whitelist=True, merge_whitelist_usernames=[GITEA_OWNER], block_on_rejected_reviews=True, block_on_outdated_branch=True, # Critical for DB repos require_signed_commits=False, ) # Display configuration print(f"\n{'=' * 60}") print(f"Gitea Branch Protection Configuration") print(f"{'=' * 60}") print(f"Gitea URL: {GITEA_URL}") print(f"Owner: {GITEA_OWNER}") print(f"Branch: {config.branch_name}") print(f"{'=' * 60}") print(f"Protection Rules:") print(f" • Direct pushes: {'Disabled' if not config.enable_push else 'Enabled'}") print(f" • Required approvals: {config.required_approvals}") print(f" • Approvals whitelist: {', '.join(config.approvals_whitelist_usernames)}") print(f" • Dismiss stale approvals: {config.dismiss_stale_approvals}") print(f" • Status checks enabled: {config.enable_status_check}") print(f" • Merge whitelist: {', '.join(config.merge_whitelist_usernames)}") print(f" • Block on rejected reviews: {config.block_on_rejected_reviews}") print(f" • Block on outdated branch: {config.block_on_outdated_branch}") print(f" • Require signed commits: {config.require_signed_commits}") print(f"{'=' * 60}\n") if dry_run: print("🔍 DRY RUN MODE - No changes will be made\n") # Create manager and apply rules manager = GiteaBranchProtectionManager(GITEA_URL, GITEA_TOKEN, GITEA_OWNER) manager.apply_to_all_repos(config, dry_run=dry_run) if __name__ == '__main__': main()