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
- 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>
1424 lines
56 KiB
PHP
Executable File
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());
|
|
}
|