Files
Jonathan Miller cbb4d73df5
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 7s
Generic: Repo Health / Scripts governance (push) Successful in 7s
Generic: Repo Health / Repository health (push) Successful in 19s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Universal: PR Check / Build RC Package (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m19s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 59s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 58s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m1s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m0s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 49s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 51s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 1m2s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m7s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m9s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 55s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m0s
fix: PHPStan level 4 → 5 — fix 4 errors
- bulk_sync: remove redundant array_values on already-list array
- RepositorySynchronizer: fix metrics increment() — labels passed as
  2nd param (value) instead of 3rd (labels), was a real bug

PHPStan level 5: 0 errors.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 22:42:50 -05:00

1424 lines
56 KiB
PHP
Executable File

#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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;
}
// 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<int, array{name: string}> $repositories
* @return array<int, array{name: string}>
*/
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<int, array{name: string}> $repositories
* @return array<string, string> 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<int, array{name: string}> $repositories
* @return array<string, array{score: int, max: int, level: string}>
*/
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<array{0:string,1:string,2:string}> 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 = <<<MD
## MokoStandards Sync Applied
A MokoStandards bulk sync run has updated files in this repository.
| Field | Value |
|-------|-------|
| **Last sync** | {$now}{$force} |
| **Standards version** | `{$version}` |
| **Branch** | [`{$branchName}`]({$branchLink}) |
| **Pull request** | [#{$prNumber}]({$prLink}) |
| **Source** | [{$source}]({$source}) |
### What was synced?
Review the pull request above to see exactly which files were added or updated.
Protected files (README, CHANGELOG, GOVERNANCE, etc.) were not overwritten.
---
*Updated automatically by [MokoStandards]({$source}) `bulk_sync.php`*
MD;
// Dedent heredoc
$body = preg_replace('/^ /m', '', $body);
$labelNames = ['standards-update', 'mokostandards', 'type: chore', 'automation'];
$labels = $this->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 = <<<MD
## MokoStandards Bulk Sync Report
**Organisation:** `{$org}`
**Triggered:** {$now}{$force}
**Duration:** {$duration}s
{$header}
{$separator}
{$table}
---
**Summary:** {$success} updated · {$skipped} skipped · {$failed} failed · {$total} total
MD;
// Dedent heredoc indentation
$body = preg_replace('/^ /m', '', $body);
try {
// Search for existing issue by label — any state so we can reopen closed ones
$existing = $this->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 = <<<MD
## Bulk Sync Failure
`bulk_sync.php` v{$version} encountered failures during the sync run on {$now}.
### Failed repositories
{$repoList}
### Next steps
1. Check the local audit log or re-run with `--repos=<repo>` 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=<repo> --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());
}