* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * SPDX-License-Identifier: GPL-3.0-or-later * * Enforce branch protection rules across all repos in the org. * * Usage: * php cli/branch_protect_org.php --token TOKEN [--org MokoConsulting] [--dry-run] * * Branch flow: feature/* -> dev -> rc -> main * main, dev, rc: push whitelist only (no direct push) * alpha, beta: push whitelist only (pre-release) */ declare(strict_types=1); $options = getopt('', ['token:', 'org:', 'api-base:', 'dry-run', 'help']); if (isset($options['help']) || empty($options['token'])) { echo "Usage: php cli/branch_protect_org.php --token TOKEN [--org ORG] [--api-base URL] [--dry-run]\n"; echo "\n"; echo "Options:\n"; echo " --token Gitea API token (required)\n"; echo " --org Organization name (default: MokoConsulting)\n"; echo " --api-base API base URL (default: https://git.mokoconsulting.tech/api/v1)\n"; echo " --dry-run Show what would be changed without making changes\n"; exit(0); } $token = $options['token']; $org = $options['org'] ?? 'MokoConsulting'; $apiBase = rtrim($options['api-base'] ?? 'https://git.mokoconsulting.tech/api/v1', '/'); $dryRun = isset($options['dry-run']); // Protected branches and their rules $branchRules = [ // Primary branches (flow: feature/* -> dev -> rc -> main) 'main' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], 'dev' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], 'rc' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], 'beta' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], 'alpha' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], // Synonyms (prevent bypass via alternate names) 'master' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], 'develop' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], 'release' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], 'production' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], 'stable' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], 'staging' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], ]; function apiRequest(string $method, string $url, string $token, ?array $body = null): array { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: token ' . $token, 'Content-Type: application/json', 'Accept: application/json', ], CURLOPT_TIMEOUT => 30, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return [ 'status' => $httpCode, 'data' => json_decode($response, true) ?: [], ]; } // 1. List all org repos echo "Fetching repos for {$org}...\n"; $page = 1; $repos = []; do { $result = apiRequest('GET', "{$apiBase}/orgs/{$org}/repos?limit=50&page={$page}", $token); $batch = $result['data']; $repos = array_merge($repos, $batch); $page++; } while (count($batch) === 50); echo sprintf("Found %d repos\n\n", count($repos)); $summary = ['protected' => 0, 'added' => 0, 'skipped' => 0, 'errors' => 0]; foreach ($repos as $repo) { $repoName = $repo['name']; if ($repo['archived'] ?? false) { continue; } // Get existing protections $existing = apiRequest('GET', "{$apiBase}/repos/{$org}/{$repoName}/branch_protections", $token); $existingNames = array_map(fn($p) => $p['branch_name'] ?? '', $existing['data'] ?: []); $added = []; $skipped = []; foreach ($branchRules as $branch => $rules) { if (in_array($branch, $existingNames, true)) { $skipped[] = $branch; $summary['skipped']++; continue; } if ($dryRun) { $added[] = $branch; $summary['added']++; continue; } $body = array_merge($rules, ['branch_name' => $branch]); $result = apiRequest('POST', "{$apiBase}/repos/{$org}/{$repoName}/branch_protections", $token, $body); if ($result['status'] >= 200 && $result['status'] < 300) { $added[] = $branch; $summary['added']++; } elseif ($result['status'] === 422) { $skipped[] = $branch; $summary['skipped']++; } else { $added[] = "{$branch}(ERR:{$result['status']})"; $summary['errors']++; } } $summary['protected']++; if (!empty($added)) { $prefix = $dryRun ? '[DRY-RUN] ' : ''; echo sprintf(" %s%-35s added: %s\n", $prefix, $repoName, implode(', ', $added)); } } echo "\n"; echo sprintf("Summary: %d repos, %d rules added, %d already existed, %d errors\n", $summary['protected'], $summary['added'], $summary['skipped'], $summary['errors']); if ($dryRun) { echo "\n(Dry run - no changes made)\n"; }