#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Automation * INGROUP: MokoStandards.Scripts * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /automation/bulk_sync.php * BRIEF: Enterprise-grade bulk repository synchronization */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\{ ApiClient, AuditLogger, CheckpointManager, CircuitBreakerOpen, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory, PluginFactory, ProjectTypeDetector, RateLimitExceeded, RepositorySynchronizer, SecurityValidator, SynchronizationNotImplementedException }; /** * Bulk Repository Synchronization Tool * * Synchronizes MokoStandards files across multiple repositories using * the Enterprise library for robust, audited operations. */ class BulkSync extends CliFramework { /** * Default organization for bulk sync operations * Public to allow script instantiation with class constants */ public const DEFAULT_ORG = 'MokoConsulting'; /** * Script version number * Public to allow script instantiation with class constants */ public const VERSION = '04.06.00'; public const VERSION_MINOR = '04.05'; private ApiClient $api; private GitPlatformAdapter $adapter; private RepositorySynchronizer $synchronizer; private AuditLogger $logger; private CheckpointManager $checkpoints; private MetricsCollector $metrics; private Config $config; /** Set to true by signal handler or rate-limit detection to abort the sync loop gracefully. */ private bool $interrupted = false; /** * Setup command-line arguments */ protected function configure(): void { $this->setDescription('Bulk repository synchronization'); $this->addArgument('--org', 'Organization', self::DEFAULT_ORG); $this->addArgument('--repos', 'Specific repos', ''); $this->addArgument('--exclude', 'Repos to exclude', ''); $this->addArgument('--skip-archived', 'Skip archived repos', false); $this->addArgument('--yes', 'Auto-confirm', false); $this->addArgument('--resume', 'Resume from checkpoint', false); $this->addArgument('--force', 'Force overwrite', false); $this->addArgument('--protect', 'Apply branch protection', false); $this->addArgument('--no-issue', 'Skip tracking issue', false); $this->addArgument('--update-branches', 'Merge main into branches', false); $this->addArgument('--health', 'Run health checks', false); } /** * Main execution */ protected function run(): int { $this->log("๐Ÿš€ MokoStandards Bulk Synchronization v" . self::VERSION, 'INFO'); // Initialize enterprise components if (!$this->initializeComponents()) { return 1; } // Get configuration $org = $this->getArgument('--org', self::DEFAULT_ORG); $skipArchived = $this->getArgument('--skip-archived', false); $autoConfirm = $this->getArgument('--yes', false); // Get repository filters $specificRepos = $this->parseRepositoryList($this->getArgument('--repos', '')); $excludeRepos = $this->parseRepositoryList($this->getArgument('--exclude', '')); $this->log("Organization: {$org}", 'INFO'); if (!empty($specificRepos)) { $this->log("Repositories: " . implode(', ', $specificRepos), 'INFO'); } if (!empty($excludeRepos)) { $this->log("Excluding: " . implode(', ', $excludeRepos), 'INFO'); } // Get repositories $this->log("๐Ÿ“‹ Fetching repositories...", 'INFO'); $repositories = $this->synchronizer->getRepositories($org, $skipArchived); // Apply filters $repositories = $this->filterRepositories($repositories, $specificRepos, $excludeRepos); $count = count($repositories); $this->log("Found {$count} repositories to sync", 'INFO'); if ($count === 0) { $this->log("No repositories to process", 'WARN'); return 0; } // Load resume checkpoint if --resume is set $alreadyProcessed = []; if ($this->getArgument('--resume', false)) { $checkpoint = $this->checkpoints->loadCheckpoint('bulk_sync'); if ($checkpoint !== null) { $alreadyProcessed = array_keys($checkpoint['results']['repositories'] ?? []); $skipCount = count($alreadyProcessed); $stoppedAt = $checkpoint['stopped_at'] ?? 'unknown'; $reason = $checkpoint['stopped_reason'] ?? 'unknown'; $this->log("โ–ถ Resuming from checkpoint ({$reason} at '{$stoppedAt}') โ€” skipping {$skipCount} already-processed repositories", 'INFO'); } else { $this->log("โš ๏ธ No checkpoint found, starting from scratch", 'WARN'); } } // Confirm before proceeding $remaining = $count - count($alreadyProcessed); if (!$autoConfirm && !$this->confirmSync($remaining > 0 ? $remaining : $count)) { $this->log("โŒ Sync cancelled by user", 'INFO'); return 0; } // Sync universal workflows from Template-Generic โ†’ other templates first $this->log("๐Ÿ“‹ Syncing universal workflows to template repos...", 'INFO'); $templateUpdates = $this->synchronizer->syncUniversalWorkflowsToTemplates($org); $this->log("Template sync: {$templateUpdates} file(s) updated", 'INFO'); // Execute synchronization $this->log("๐Ÿ”„ Starting synchronization...", 'INFO'); $results = $this->executeSynchronization($org, $repositories, $alreadyProcessed); // Display results $this->displayResults($results); // Apply branch protection if --protect flag is set if (isset($this->options['protect'])) { $this->log("๐Ÿ”’ Applying branch protection rules...", 'INFO'); $results['protection'] = $this->applyBranchProtectionAll($org, $repositories); } // Run repo health checks if --health flag is set if (isset($this->options['health'])) { $this->log("๐Ÿฉบ Running repository health checks...", 'INFO'); $results['health'] = $this->runHealthChecksAll($org, $repositories); } // Create/update tracking issue in MokoStandards $this->createSyncIssue($org, $results); // Create/update a failure issue when any repos failed if ($results['failed'] > 0) { $this->createFailureIssue($org, $results); } return $results['failed'] > 0 ? 1 : 0; } /** * Initialize enterprise components */ private function initializeComponents(): bool { $this->config = Config::load(); $platform = $this->config->getString('platform', 'github'); try { $this->adapter = PlatformAdapterFactory::create($this->config); $this->api = $this->adapter->getApiClient(); $this->logger = new AuditLogger('bulk_sync'); $this->metrics = new MetricsCollector(); $this->checkpoints = new CheckpointManager('.checkpoints'); $this->synchronizer = new RepositorySynchronizer( $this->api, $this->logger, $this->metrics, $this->checkpoints, null, $this->adapter ); // Initialize plugin system $this->log("โœ“ Enterprise components initialized for platform: {$platform}", 'INFO'); return true; } catch (\Exception $e) { $this->log("โŒ Failed to initialize: " . $e->getMessage(), 'ERROR'); return false; } } /** * Parse repository list from string */ private function parseRepositoryList(string $input): array { if (empty($input)) { return []; } return array_filter( array_map('trim', preg_split('/[\s,]+/', $input)), fn($r) => !empty($r) ); } /** * Filter repositories based on include/exclude lists */ /** Repositories that are permanently excluded from bulk sync. */ private const ALWAYS_EXCLUDE = ['MokoStandards', '.github-private']; private function filterRepositories(array $repositories, array $include, array $exclude): array { // Apply include filter if specified (but never override permanent exclusions) if (!empty($include)) { $repositories = array_filter( $repositories, fn($repo) => in_array($repo['name'], $include) ); } // Merge user excludes with permanent excludes $allExclude = array_unique(array_merge($exclude, self::ALWAYS_EXCLUDE)); $repositories = array_filter( $repositories, fn($repo) => !in_array($repo['name'], $allExclude) ); return array_values($repositories); } /** * Sort repositories so that .github-private is always processed first. * All other repositories retain their original relative order. * * @param array $repositories * @return array */ private function prioritizeRepositories(array $repositories): array { $priority = []; $rest = []; foreach ($repositories as $repo) { if ($repo['name'] === '.github-private') { $priority[] = $repo; } else { $rest[] = $repo; } } return array_merge($priority, $rest); } /** * Confirm synchronization with user */ private function confirmSync(int $count): bool { if ($this->quiet) { return true; } echo "\nโš ๏ธ About to synchronize {$count} repositories.\n"; echo "This will update files across all repositories.\n"; echo "\nContinue? [y/N]: "; $handle = fopen("php://stdin", "r"); $line = fgets($handle); if ($handle) { fclose($handle); } // fgets() returns false when stdin is not a TTY (e.g. CI, piped input); // treat that as a non-confirmation rather than crashing. return is_string($line) && strtolower(trim($line)) === 'y'; } /** * Execute synchronization across repositories * * @param array $alreadyProcessed Repo names to skip (from a resumed checkpoint) */ private function executeSynchronization(string $org, array $repositories, array $alreadyProcessed = []): array { $results = [ 'total' => count($repositories), 'success' => 0, 'skipped' => 0, 'failed' => 0, 'repositories' => [], 'prs' => [], 'issues' => [], ]; // Seed results with repos that were already processed so the final // summary and issue reflect the full run, not just the resumed portion. foreach ($alreadyProcessed as $name) { $results['repositories'][$name] = 'skipped (resumed)'; $results['skipped']++; } // Register signal handlers so Ctrl-C / SIGTERM saves a resume checkpoint // instead of leaving the run in an unknown state. if (function_exists('pcntl_async_signals')) { pcntl_async_signals(true); pcntl_signal(SIGINT, function () { $this->interrupted = true; }); pcntl_signal(SIGTERM, function () { $this->interrupted = true; }); } $startTime = microtime(true); foreach ($repositories as $index => $repo) { $repoName = $repo['name']; $progress = $index + 1; $total = $results['total']; // Skip repos already covered by a previous partial run if (in_array($repoName, $alreadyProcessed, true)) { $this->log("[{$progress}/{$total}] โŠ˜ {$repoName} (already processed โ€” skipping)", 'INFO'); continue; } // Check for Ctrl-C / SIGTERM before starting each repo if ($this->interrupted) { $this->log("โšก Interrupted before {$repoName} โ€” saving checkpoint", 'WARN'); $this->saveInterruptCheckpoint($results, $repoName, 'interrupted'); break; } $this->log("[{$progress}/{$total}] Processing {$repoName}...", 'INFO'); // Reset circuit breaker before processing each repository // This prevents failures on one repo from blocking subsequent repos $this->api->resetCircuitBreaker(); // Ensure standard labels exist on the target repo before syncing. // Label provisioning is non-critical โ€” if the circuit breaker trips // (e.g. 54 new labels on a fresh repo), reset it so file sync proceeds. if (!$this->dryRun) { $this->ensureRepoLabels($org, $repoName); $this->ensureReleaseTags($org, $repoName); $this->api->resetCircuitBreaker(); } try { $updated = $this->synchronizer->processRepository( $org, $repoName, $this->dryRun, isset($this->options['force']) ); if ($updated !== false && $updated > 0) { $results['success']++; $results['repositories'][$repoName] = 'success'; $results['prs'][$repoName] = $updated; $this->log(" โœ“ {$repoName} updated", 'INFO'); if (!isset($this->options['no-issue']) && !$this->dryRun) { $issueNum = $this->createTargetRepoIssue($org, $repoName, $updated); if ($issueNum !== null) { $results['issues'][$repoName] = $issueNum; } } if (isset($this->options['update-branches']) && !$this->dryRun) { $this->updateOpenBranches($org, $repoName); } } else { $results['skipped']++; $results['repositories'][$repoName] = 'skipped'; $this->log(" โŠ˜ {$repoName} skipped", 'INFO'); } } catch (SynchronizationNotImplementedException $e) { $this->log("", 'ERROR'); $this->log("โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—", 'ERROR'); $this->log("โ•‘ CRITICAL ERROR: Repository Synchronization Not Implemented โ•‘", 'ERROR'); $this->log("โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", 'ERROR'); $this->log("", 'ERROR'); $this->log("The bulk repository sync is failing silently because the core", 'ERROR'); $this->log("synchronization logic has not been implemented yet.", 'ERROR'); $this->log("", 'ERROR'); $this->log("Location: lib/Enterprise/RepositorySynchronizer.php", 'ERROR'); $this->log("Method: processRepository()", 'ERROR'); $this->log("", 'ERROR'); $this->log("Required Implementation:", 'ERROR'); $this->log(" 1. Clone/fetch target repository", 'ERROR'); $this->log(" 2. Apply file updates based on MokoStandards configuration", 'ERROR'); $this->log(" 3. Create pull request with changes", 'ERROR'); $this->log(" 4. Handle merge conflicts and validation", 'ERROR'); $this->log("", 'ERROR'); $this->log("Until this is implemented, bulk sync will not function.", 'ERROR'); $this->log("", 'ERROR'); throw $e; } catch (CircuitBreakerOpen $e) { $results['failed']++; $results['repositories'][$repoName] = 'failed'; $this->log(" โœ— {$repoName} failed: Circuit breaker open - " . $e->getMessage(), 'ERROR'); } catch (RateLimitExceeded $e) { // Rate limit hit โ€” abort immediately so we don't burn retries on 403s $results['failed']++; $results['repositories'][$repoName] = 'failed'; $this->log(" โœ— {$repoName} rate-limited: " . $e->getMessage(), 'ERROR'); $this->saveInterruptCheckpoint($results, $repoName, 'rate_limited'); break; } catch (\Exception $e) { // Also catch rate limits surfaced as generic exceptions by ApiClient retries if ($this->isRateLimitError($e)) { $results['failed']++; $results['repositories'][$repoName] = 'failed'; $this->log(" โœ— {$repoName} rate-limited โ€” stopping sync", 'ERROR'); $this->saveInterruptCheckpoint($results, $repoName, 'rate_limited'); break; } $results['failed']++; $results['repositories'][$repoName] = 'failed'; $this->log(" โœ— {$repoName} failed: " . $e->getMessage(), 'ERROR'); } // Save rolling checkpoint after each repo (skipped in dry-run) if (!$this->dryRun) { $this->checkpoints->saveCheckpoint('bulk_sync', [ 'processed' => $progress, 'total' => $total, 'results' => $results, 'stopped_at' => $repoName, 'stopped_reason' => 'checkpoint', ]); } } $duration = microtime(true) - $startTime; $results['duration'] = $duration; return $results; } /** * Return true when an exception message indicates a GitHub rate-limit response. * Catches 403 rate-limit errors that ApiClient wraps as generic exceptions. */ private function isRateLimitError(\Exception $e): bool { $msg = strtolower($e->getMessage()); return str_contains($msg, 'rate limit') || str_contains($msg, '429') || (str_contains($msg, '403') && str_contains($msg, 'rate')); } /** * Save a checkpoint that records where the sync was interrupted, then print * a hint showing the exact command needed to resume. */ private function saveInterruptCheckpoint(array $results, string $stoppedAt, string $reason): void { if ($this->dryRun) { return; } try { $this->checkpoints->saveCheckpoint('bulk_sync', [ 'processed' => count($results['repositories']), 'total' => $results['total'], 'results' => $results, 'stopped_at' => $stoppedAt, 'stopped_reason' => $reason, ]); $script = basename(__FILE__); $this->log("๐Ÿ’พ Checkpoint saved. To resume once the issue is resolved, run:", 'INFO'); $this->log(" php automation/{$script} --resume [same flags as before]", 'INFO'); } catch (\Exception $e) { $this->log("โš ๏ธ Failed to save interrupt checkpoint: " . $e->getMessage(), 'WARN'); } } /** * Display synchronization results */ private function displayResults(array $results): void { $this->log("\n" . str_repeat('=', 60), 'INFO'); $this->log("๐Ÿ“Š Synchronization Complete", 'INFO'); $this->log(str_repeat('=', 60), 'INFO'); $total = $results['total']; $success = $results['success']; $skipped = $results['skipped']; $failed = $results['failed']; $duration = $results['duration']; $successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0; $this->log(sprintf("Total: %d repositories", $total), 'INFO'); $this->log(sprintf("Success: %d (โœ“)", $success), 'INFO'); $this->log(sprintf("Skipped: %d (โŠ˜)", $skipped), 'INFO'); $this->log(sprintf("Failed: %d (โœ—)", $failed), 'INFO'); $this->log(sprintf("Success Rate: %.1f%%", $successRate), 'INFO'); $this->log(sprintf("Duration: %.2f seconds", $duration), 'INFO'); if ($failed > 0) { $this->log("\nโš ๏ธ Failed Repositories:", 'WARN'); foreach ($results['repositories'] as $repo => $status) { if ($status === 'failed') { $this->log(" - {$repo}", 'WARN'); } } } if ($this->verbose) { $this->log("\n๐Ÿ“‹ Repository Details:", 'INFO'); foreach ($results['repositories'] as $repo => $status) { $icon = match ($status) { 'success' => 'โœ“', 'skipped' => 'โŠ˜', 'failed' => 'โœ—', default => '?' }; $this->log(sprintf(" %s %s: %s", $icon, $repo, $status), 'INFO'); } } $this->log(str_repeat('=', 60), 'INFO'); $this->writeStepSummary($results); } /** * Write synchronization results to the GitHub Actions step summary. * * Appends a Markdown summary table listing every repository that was * processed โ€” together with its outcome (updated, skipped, or failed) โ€” * to the file referenced by the GITHUB_STEP_SUMMARY environment variable. * When that variable is not set (e.g. local runs) the method is a no-op. */ private function writeStepSummary(array $results): void { // Check both GitHub and Gitea step summary env vars $summaryFile = getenv($this->adapter->getStepSummaryEnvVar()); if (empty($summaryFile)) { // Fallback: also check the other platform's env var $fallback = $this->adapter->getPlatformName() === 'github' ? getenv('GITEA_STEP_SUMMARY') : getenv('GITHUB_STEP_SUMMARY'); $summaryFile = $fallback ?: ''; } if (empty($summaryFile)) { return; } // Validate that the path is an absolute filesystem path and not a // special device file, to guard against environment variable injection. $realDir = realpath(dirname($summaryFile)); if ($realDir === false || !str_starts_with($summaryFile, '/') || strpos($summaryFile, '..') !== false) { $this->log('โš ๏ธ GITHUB_STEP_SUMMARY path is not safe, skipping step summary write.', 'WARN'); return; } $total = $results['total']; $success = $results['success']; $skipped = $results['skipped']; $failed = $results['failed']; $duration = $results['duration']; $successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0; $lines = []; $lines[] = ''; $lines[] = '### ๐Ÿ“Š Synchronization Summary'; $lines[] = ''; $lines[] = '| Total | โœ… Updated | โŠ˜ Skipped | โŒ Failed | Success Rate | Duration |'; $lines[] = '|------:|----------:|----------:|----------:|-------------:|---------:|'; $lines[] = sprintf( '| %d | %d | %d | %d | %.1f%% | %.2fs |', $total, $success, $skipped, $failed, $successRate, $duration ); $lines[] = ''; if (!empty($results['repositories'])) { $lines[] = '### ๐Ÿ“‹ Repositories Processed'; $lines[] = ''; $lines[] = '| Repository | Status |'; $lines[] = '|:-----------|:-------|'; foreach ($results['repositories'] as $repo => $status) { $label = match ($status) { 'success' => 'โœ… Updated', 'skipped' => 'โŠ˜ Skipped', 'failed' => 'โŒ Failed', default => $status, }; $lines[] = sprintf('| `%s` | %s |', $repo, $label); } $lines[] = ''; } $written = file_put_contents($summaryFile, implode("\n", $lines) . "\n", FILE_APPEND); if ($written === false) { $this->log('โš ๏ธ Failed to write to GITHUB_STEP_SUMMARY.', 'WARN'); } } /** * Apply main branch protection to all repositories. * * Tries classic branch protection first; records the outcome per repo. * Private repos on the free GitHub plan will receive a 403 โ€” those are * noted but do not count as failures. * * @param array $repositories * @return array repo name => 'protected'|'skipped'|'no_main'|'plan_limit'|'error' */ private function applyBranchProtectionAll(string $org, array $repositories): array { $protection = []; foreach ($repositories as $repo) { $name = $repo['name']; if ($this->dryRun) { $this->log(" (dry-run) would protect {$name}/main", 'INFO'); $protection[$name] = 'skipped'; continue; } try { $this->adapter->setBranchProtection($org, $name, 'main', [ 'required_reviews' => 1, 'dismiss_stale' => false, 'enforce_admins' => false, 'require_code_owner' => false, ]); $protection[$name] = 'protected'; $this->log(" ๐Ÿ”’ {$name}: main branch protected", 'INFO'); } catch (\Exception $e) { $msg = $e->getMessage(); if (str_contains($msg, '403')) { $protection[$name] = 'plan_limit'; $this->log(" โš ๏ธ {$name}: branch protection requires upgraded plan (private repo)", 'WARN'); } elseif (str_contains($msg, '404')) { $protection[$name] = 'no_main'; $this->log(" โŠ˜ {$name}: no main branch found", 'INFO'); } else { $protection[$name] = 'error'; $this->log(" โœ— {$name}: {$msg}", 'ERROR'); } $this->api->resetCircuitBreaker(); } } $protectedCount = count(array_filter($protection, fn($v) => $v === 'protected')); $planLimitCount = count(array_filter($protection, fn($v) => $v === 'plan_limit')); $this->log(sprintf( "๐Ÿ”’ Branch protection: %d protected, %d require upgraded plan", $protectedCount, $planLimitCount ), 'INFO'); return $protection; } /** * Run lightweight health checks on all repositories after sync. * * Checks rulesets (MAIN, VERSION, DEV) and branch protection via the GitHub API. * Returns a map of repo name => ['score' => int, 'max' => int, 'level' => string]. * * @param array $repositories * @return array */ private function runHealthChecksAll(string $org, array $repositories): array { $health = []; foreach ($repositories as $repo) { $name = $repo['name']; $score = 0; $max = 0; // 1. Check branch protection rules (rulesets on GitHub, branch_protections on Gitea) $max += 20; try { $protections = $this->adapter->listBranchProtections($org, $name); $hasMain = $hasVersion = $hasDev = $hasRc = false; foreach ($protections as $prot) { $protName = strtolower($prot['name'] ?? $prot['branch_name'] ?? ''); $refs = $prot['conditions']['ref_name']['include'] ?? []; if (str_contains($protName, 'main') || in_array('refs/heads/main', $refs, true)) { $hasMain = true; } if (str_contains($protName, 'version') || $this->refsContain($refs, 'version')) { $hasVersion = true; } if ( (str_contains($protName, 'dev') && !str_contains($protName, 'develop')) || $this->refsContain($refs, 'dev') ) { $hasDev = true; } if (str_contains($protName, 'rc') || $this->refsContain($refs, 'rc/')) { $hasRc = true; } } if ($hasMain) { $score += 5; } if ($hasVersion) { $score += 5; } if ($hasDev) { $score += 5; } if ($hasRc) { $score += 5; } } catch (\Exception $e) { $this->api->resetCircuitBreaker(); } // 2. Check branch protection on main (10 pts) $max += 10; $hasMainProtection = $this->checkBranchProtected($org, $name); if ($hasMainProtection) { $score += 10; } // Calculate level $pct = $max > 0 ? ($score / $max * 100) : 0; $level = match (true) { $pct >= 90 => 'excellent', $pct >= 70 => 'good', $pct >= 50 => 'fair', default => 'poor', }; $health[$name] = ['score' => $score, 'max' => $max, 'level' => $level]; if ($pct < 70) { $this->log(" โš ๏ธ {$name}: health {$score}/{$max} ({$level})", 'WARN'); } else { $this->log(" โœ“ {$name}: health {$score}/{$max} ({$level})", 'INFO'); } } $excellent = count(array_filter($health, fn($h) => $h['level'] === 'excellent')); $good = count(array_filter($health, fn($h) => $h['level'] === 'good')); $fair = count(array_filter($health, fn($h) => $h['level'] === 'fair')); $poor = count(array_filter($health, fn($h) => $h['level'] === 'poor')); $this->log(sprintf( "๐Ÿฉบ Health: %d excellent, %d good, %d fair, %d poor", $excellent, $good, $fair, $poor ), 'INFO'); return $health; } /** * Check if any ref patterns in the array contain a given keyword. */ private function refsContain(array $refs, string $keyword): bool { foreach ($refs as $ref) { if (str_contains($ref, $keyword)) { return true; } } return false; } /** * Check if a repo's main branch has protection enabled. * * @return bool True if main branch is protected */ private function checkBranchProtected(string $org, string $repo): bool { try { $protections = $this->adapter->listBranchProtections($org, $repo); foreach ($protections as $prot) { $name = strtolower($prot['name'] ?? $prot['branch_name'] ?? ''); if (str_contains($name, 'main')) { return true; } } } catch (\Exception $e) { $this->api->resetCircuitBreaker(); } return false; } /** * Ensure all standard MokoStandards labels exist on a target repository. * * Fetches existing labels first (GET) and only POSTs the ones that are * missing. This avoids the 422 "already exists" responses that would * otherwise accumulate and trip the circuit breaker on subsequent runs. */ private function ensureRepoLabels(string $org, string $repo): void { /** @var list name, hex colour (no #), description */ $labels = [ // Project Type ['joomla', '7F52FF', 'Joomla extension or component'], ['dolibarr', 'FF6B6B', 'Dolibarr module or extension'], ['generic', '808080', 'Generic project or library'], // Language ['php', '4F5D95', 'PHP code changes'], ['javascript', 'F7DF1E', 'JavaScript code changes'], ['typescript', '3178C6', 'TypeScript code changes'], ['python', '3776AB', 'Python code changes'], ['css', '1572B6', 'CSS/styling changes'], ['html', 'E34F26', 'HTML template changes'], // Component ['documentation', '0075CA', 'Documentation changes'], ['ci-cd', '000000', 'CI/CD pipeline changes'], ['docker', '2496ED', 'Docker configuration changes'], ['tests', '00FF00', 'Test suite changes'], ['security', 'FF0000', 'Security-related changes'], ['dependencies', '0366D6', 'Dependency updates'], ['config', 'F9D0C4', 'Configuration file changes'], ['build', 'FFA500', 'Build system changes'], // Workflow / Process ['automation', '8B4513', 'Automated processes or scripts'], ['mokostandards', 'B60205', 'MokoStandards compliance'], ['needs-review', 'FBCA04', 'Awaiting code review'], ['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'], ['breaking-change', 'D73A4A', 'Breaking API or functionality change'], // Priority ['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'], ['priority: high', 'D93F0B', 'High priority'], ['priority: medium', 'FBCA04', 'Medium priority'], ['priority: low', '0E8A16', 'Low priority'], // Type ['type: bug', 'D73A4A', "Something isn't working"], ['type: feature', 'A2EEEF', 'New feature or request'], ['type: enhancement', '84B6EB', 'Enhancement to existing feature'], ['type: refactor', 'F9D0C4', 'Code refactoring'], ['type: chore', 'FEF2C0', 'Maintenance tasks'], // Status ['status: pending', 'FBCA04', 'Pending action or decision'], ['status: in-progress', '0E8A16', 'Currently being worked on'], ['status: blocked', 'B60205', 'Blocked by another issue or dependency'], ['status: on-hold', 'D4C5F9', 'Temporarily on hold'], ['status: wontfix', 'FFFFFF', 'This will not be worked on'], // Size ['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'], ['size/s', '6FD1E2', 'Small change (11-30 lines)'], ['size/m', 'F9DD72', 'Medium change (31-100 lines)'], ['size/l', 'FFA07A', 'Large change (101-300 lines)'], ['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'], ['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'], // Health ['health: excellent', '0E8A16', 'Health score 90-100'], ['health: good', 'FBCA04', 'Health score 70-89'], ['health: fair', 'FFA500', 'Health score 50-69'], ['health: poor', 'FF6B6B', 'Health score below 50'], // Sync / Automation (used by bulk_sync, scan_drift, check_repo_health) ['standards-update', 'B60205', 'MokoStandards sync update'], ['standards-drift', 'FBCA04', 'Repository drifted from MokoStandards'], ['sync-report', '0075CA', 'Bulk sync run report'], ['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'], ['push-failure', 'D73A4A', 'File push failure requiring attention'], ['health-check', '0E8A16', 'Repository health check results'], ['version-drift', 'FFA500', 'Version mismatch detected'], ['deploy-failure', 'CC0000', 'Automated deploy failure tracking'], ['template-validation-failure', 'D73A4A', 'Template workflow validation failure'], ['version', '0E8A16', 'Version bump or release'], ['type: version', '0E8A16', 'Version-related change'], ]; // Quick check: if the repo already has the 'mokostandards' label, it was // provisioned previously โ€” skip the expensive full label provisioning. try { $probe = $this->api->get("/repos/{$org}/{$repo}/labels/mokostandards"); if (!empty($probe['name'])) { return; // already provisioned } } catch (\Exception $e) { // Label doesn't exist โ€” proceed with full provisioning } // Fetch existing labels to determine which ones need creating. $existing = []; try { $page = 1; do { $page_labels = $this->api->get("/repos/{$org}/{$repo}/labels?per_page=100&page={$page}"); foreach ($page_labels as $label) { $existing[strtolower($label['name'])] = true; } $page++; } while (count($page_labels) === 100); } catch (\Exception $e) { // Cannot read labels (e.g. no access) โ€” skip provisioning entirely return; } foreach ($labels as [$name, $color, $description]) { if (isset($existing[strtolower($name)])) { continue; // already exists โ€” no POST needed } // Reset before each attempt โ€” the circuit breaker checks state at the // START of each API call, so resetting after a failure is too late. $this->api->resetCircuitBreaker(); try { $this->api->post("/repos/{$org}/{$repo}/labels", [ 'name' => $name, 'color' => $color, 'description' => $description, ]); } catch (\Exception $e) { // Ignore โ€” label already exists or transient failure } } } /** * Ensure standard release tags exist on the repository. * * Creates 'development', 'beta', and 'release-candidate' tags pointing * to the default branch HEAD if they don't already exist. These tags * are used by the release workflow to track stability channels. */ private function ensureReleaseTags(string $org, string $repo): void { $requiredTags = ['development', 'beta', 'release-candidate']; try { $existingTags = $this->api->get("/repos/{$org}/{$repo}/tags", ['limit' => 50]); } catch (\Exception $e) { return; // Non-critical } $existingNames = array_column($existingTags, 'name'); // Get default branch to point new tags at try { $repoInfo = $this->api->get("/repos/{$org}/{$repo}"); $defaultBranch = $repoInfo['default_branch'] ?? 'main'; } catch (\Exception $e) { $defaultBranch = 'main'; } foreach ($requiredTags as $tagName) { if (in_array($tagName, $existingNames, true)) { continue; } try { $this->api->post("/repos/{$org}/{$repo}/tags", [ 'tag_name' => $tagName, 'target' => $defaultBranch, 'message' => "Release channel: {$tagName}", ]); $this->log(" ๐Ÿท๏ธ Created tag '{$tagName}' on {$repo}", 'INFO'); } catch (\Exception $e) { // Non-critical โ€” tag may already exist as a release tag } } } /** * Merge main into all open PR branches (except the sync branch itself). * * This ensures feature/development branches stay up to date with the * latest synced standards after a bulk sync run. */ private function updateOpenBranches(string $org, string $repo): void { $syncBranchPrefix = 'chore/sync-mokostandards-'; try { $defaultBranch = 'main'; try { $repoInfo = $this->api->get("/repos/{$org}/{$repo}"); $defaultBranch = $repoInfo['default_branch'] ?? 'main'; } catch (\Exception $e) { /* fallback to main */ } $prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [ 'state' => 'open', 'per_page' => 30, 'sort' => 'updated', 'direction' => 'desc', ]); foreach ($prs as $pr) { $branch = $pr['head']['ref'] ?? ''; $prNum = $pr['number'] ?? 0; // Skip sync branches โ€” they were just reset from main if (str_starts_with($branch, $syncBranchPrefix)) { continue; } try { $this->api->post("/repos/{$org}/{$repo}/merges", [ 'base' => $branch, 'head' => $defaultBranch, 'commit_message' => "chore: merge {$defaultBranch} into {$branch} (MokoStandards sync)", ]); $this->log(" ๐Ÿ”€ Merged {$defaultBranch} โ†’ {$branch} (PR #{$prNum})", 'INFO'); } catch (\Exception $e) { $msg = $e->getMessage(); if (str_contains($msg, '409') || str_contains($msg, 'Merge conflict')) { $this->log(" โš ๏ธ Merge conflict: {$defaultBranch} โ†’ {$branch} (PR #{$prNum})", 'WARN'); } elseif (str_contains($msg, '204') || str_contains($msg, 'nothing to merge')) { $this->log(" โœ“ Already up to date: {$branch}", 'DEBUG'); } else { $this->log(" โš ๏ธ Could not merge into {$branch}: " . $msg, 'WARN'); } } } } catch (\Exception $e) { $this->log(" โš ๏ธ Could not update branches in {$repo}: " . $e->getMessage(), 'WARN'); } } /** * Records which sync run touched the repo, the PR number, and the * MokoStandards version that was applied โ€” giving each repo a clear audit * trail of what was changed and why. */ /** * Resolve label names to their integer IDs for the Gitea API. * Creates missing labels automatically. * * @param string $org Organization name * @param string $repo Repository name * @param string[] $labelNames Label names to resolve * @return int[] Array of label IDs */ private function resolveLabelIds(string $org, string $repo, array $labelNames): array { try { $existing = $this->api->get("/repos/{$org}/{$repo}/labels", ['limit' => 50]); } catch (\Exception $e) { return []; } $nameToId = []; foreach ($existing as $label) { $nameToId[$label['name']] = (int) $label['id']; } $ids = []; foreach ($labelNames as $name) { if (isset($nameToId[$name])) { $ids[] = $nameToId[$name]; } // Skip labels that don't exist (ensureRepoLabels creates them separately) } return $ids; } private function createTargetRepoIssue(string $org, string $repo, int $prNumber): ?int { $now = gmdate('Y-m-d H:i:s') . ' UTC'; $version = self::VERSION; $minor = self::VERSION_MINOR; $force = isset($this->options['force']) ? ' *(--force)*' : ''; $prLink = $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber); $source = $this->adapter->getRepoWebUrl($org, 'MokoStandards'); $branchName = 'chore/sync-mokostandards-v' . $minor; $branchLink = $this->adapter->getBranchWebUrl($org, $repo, $branchName); $title = "chore: MokoStandards v{$minor} sync tracking"; $body = <<resolveLabelIds($org, $repo, $labelNames); try { // Check for an existing tracking issue (any state so we can reopen closed ones) $existing = $this->api->get("/repos/{$org}/{$repo}/issues", [ 'labels' => 'standards-update', 'state' => 'all', 'per_page' => 1, 'sort' => 'created', 'direction' => 'desc', ]); $existing = array_values($existing); if (!empty($existing) && isset($existing[0]['number'])) { $num = $existing[0]['number']; $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; if (($existing[0]['state'] ?? 'open') === 'closed') { $patch['state'] = 'open'; } $this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch); // Re-apply labels in case any were removed try { $this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]); } catch (\Exception $le) { /* non-fatal */ } $this->log(" ๐Ÿ“‹ Tracking issue #{$num} updated in {$repo}", 'INFO'); } else { $issue = $this->api->post("/repos/{$org}/{$repo}/issues", [ 'title' => $title, 'body' => $body, 'labels' => $labels, 'assignees' => ['jmiller'], ]); $num = $issue['number'] ?? '?'; $this->log(" ๐Ÿ“‹ Tracking issue #{$num} created in {$repo}", 'INFO'); } // Link the tracking issue to the sync PR so it appears in the PR's Development sidebar if (is_int($num)) { try { $pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNumber}"); $currentBody = $pr['body'] ?? ''; $closeRef = "Linked to #{$num}"; if (!str_contains($currentBody, $closeRef)) { $this->api->patch("/repos/{$org}/{$repo}/pulls/{$prNumber}", [ 'body' => $closeRef . "\n\n" . $currentBody, ]); } } catch (\Exception $le) { /* non-fatal */ } } return is_int($num) ? $num : null; } catch (\Exception $e) { $this->log(" โš ๏ธ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN'); return null; } } /** * Create a tracking issue in MokoStandards for this sync run. */ private function createSyncIssue(string $org, array $results): void { if ($this->dryRun) { return; } $now = gmdate('Y-m-d H:i:s') . ' UTC'; $total = $results['total']; $success = $results['success']; $skipped = $results['skipped']; $failed = $results['failed']; $duration = round($results['duration'] ?? 0, 1); $force = isset($this->options['force']) ? ' *(--force)*' : ''; $prs = $results['prs'] ?? []; $issues = $results['issues'] ?? []; // Stable title โ€” no timestamp so repeated runs update a single issue $title = "sync: MokoStandards v" . self::VERSION_MINOR . " bulk sync report"; $protection = $results['protection'] ?? []; $hasProtect = !empty($protection); $healthData = $results['health'] ?? []; $hasHealth = !empty($healthData); // Build repo table $rows = []; foreach ($results['repositories'] as $repo => $status) { $icon = match (true) { $status === 'success' => 'โœ…', str_starts_with($status, 'skipped') => 'โŠ˜', str_starts_with($status, 'failed') => 'โŒ', default => 'โš ๏ธ', }; $prLink = isset($prs[$repo]) ? "[#{$prs[$repo]}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prs[$repo]) . ")" : 'โ€”'; $issueLink = isset($issues[$repo]) ? "[#{$issues[$repo]}](" . $this->adapter->getIssueWebUrl($org, $repo, $issues[$repo]) . ")" : 'โ€”'; $row = "| `{$repo}` | {$icon} {$status} | {$prLink} | {$issueLink} |"; if ($hasHealth) { $h = $healthData[$repo] ?? null; if ($h) { $healthIcon = match ($h['level']) { 'excellent' => '๐ŸŸข', 'good' => '๐ŸŸก', 'fair' => '๐ŸŸ ', default => '๐Ÿ”ด', }; $row .= " {$healthIcon} {$h['score']}/{$h['max']} |"; } else { $row .= ' โ€” |'; } } $rows[] = $row; } $table = implode("\n", $rows); $header = $hasHealth ? "| Repository | Status | PR | Issue | Health |" : "| Repository | Status | PR | Issue |"; $separator = $hasHealth ? "|---|---|---|---|---|" : "|---|---|---|---|"; $body = <<api->get("/repos/{$org}/MokoStandards/issues", [ 'labels' => 'sync-report', 'state' => 'all', 'per_page' => 1, 'sort' => 'created', 'direction' => 'desc', ]); $labelNames = ['sync-report', 'mokostandards', 'type: chore', 'automation']; $labels = $this->resolveLabelIds($org, 'MokoStandards', $labelNames); $existing = array_values($existing); if (!empty($existing) && isset($existing[0]['number'])) { $issueNumber = $existing[0]['number']; $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; if (($existing[0]['state'] ?? 'open') === 'closed') { $patch['state'] = 'open'; } $this->api->patch("/repos/{$org}/MokoStandards/issues/{$issueNumber}", $patch); try { $this->api->post("/repos/{$org}/MokoStandards/issues/{$issueNumber}/labels", ['labels' => $labels]); } catch (\Exception $le) { /* non-fatal */ } $this->log("๐Ÿ“‹ Sync report issue updated: {$org}/MokoStandards#{$issueNumber}", 'INFO'); } else { $issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ 'title' => $title, 'body' => $body, 'labels' => $labels, 'assignees' => ['jmiller'], ]); $issueNumber = $issue['number'] ?? '?'; $this->log("๐Ÿ“‹ Sync report issue created: {$org}/MokoStandards#{$issueNumber}", 'INFO'); } } catch (\Exception $e) { $this->log("โš ๏ธ Failed to create/update sync report issue: " . $e->getMessage(), 'WARN'); } } /** * Create or update a failure issue in MokoStandards when repos fail to sync. * Uses the 'sync-failure' label so it is distinct from the run-report issue. * Reopens a closed issue rather than creating a duplicate. */ private function createFailureIssue(string $org, array $results): void { if ($this->dryRun) { return; } $now = gmdate('Y-m-d H:i:s') . ' UTC'; $failed = $results['failed']; $version = self::VERSION; $failedRepos = array_keys(array_filter( $results['repositories'] ?? [], fn($s) => $s === 'failed' )); $repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos)); $title = "fix: bulk_sync failed for {$failed} repo(s) โ€” action required"; $body = <<` to see the specific error. 2. Fix the underlying issue (API token, rate limit, branch protection, etc.). 3. Re-run: `php automation/bulk_sync.php --org={$org} --repos= --force --yes` 4. Close this issue once all repos are synced successfully. --- *Auto-created by `bulk_sync.php` โ€” close once resolved.* MD; $body = preg_replace('/^ /m', '', $body); try { $existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [ 'labels' => 'sync-failure', 'state' => 'all', 'per_page' => 1, 'sort' => 'created', 'direction' => 'desc', ]); $existing = array_values($existing); if (!empty($existing) && isset($existing[0]['number'])) { $num = $existing[0]['number']; $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; if (($existing[0]['state'] ?? 'open') === 'closed') { $patch['state'] = 'open'; } $this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch); $this->log("๐Ÿšจ Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN'); } else { $issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ 'title' => $title, 'body' => $body, 'labels' => $this->resolveLabelIds($org, 'MokoStandards', ['sync-failure']), 'assignees' => ['jmiller'], ]); $num = $issue['number'] ?? '?'; $this->log("๐Ÿšจ Failure issue created: {$org}/MokoStandards#{$num}", 'WARN'); } } catch (\Exception $e) { $this->log("โš ๏ธ Could not create/update failure issue: " . $e->getMessage(), 'WARN'); } } } // Execute if run directly if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) { $app = new BulkSync(); exit($app->execute()); }