* * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Enterprise * INGROUP: MokoStandards * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /lib/Enterprise/RepositorySynchronizer.php * BRIEF: Repository synchronization enterprise library */ declare(strict_types=1); namespace MokoEnterprise; use Exception; use RuntimeException; /** * Repository Synchronizer * * Enterprise library for synchronizing files across multiple repositories * based on configuration and override files. */ class RepositorySynchronizer { private const SYNC_DEFINITION_DIR = 'definitions/sync'; /** Override file path — resolved at runtime via adapter's getMetadataDir(). */ private const SYNC_OVERRIDE_FILE_SUFFIX = 'override.tf'; private const STANDARDS_VERSION = '04.06.00'; private const STANDARDS_MAJOR = '04'; // Major only — version branch is version/XX private const STANDARDS_MINOR = '04.06'; // Major.Minor for sync branch naming private const VERSION_BRANCH = 'version/' . self::STANDARDS_MAJOR; private const SYNC_BRANCH = 'chore/sync-mokostandards-v' . self::STANDARDS_MINOR; private GitPlatformAdapter $adapter; private AuditLogger $logger; private MetricsCollector $metrics; private CheckpointManager $checkpoints; private DefinitionParser $definitionParser; private MokoStandardsParser $manifestParser; /** * Constructor * * @param ApiClient $apiClient Raw API client (kept for backward compatibility) * @param AuditLogger $logger Audit logger * @param MetricsCollector $metrics Metrics collector * @param CheckpointManager|null $checkpoints Checkpoint manager * @param DefinitionParser|null $definitionParser Definition parser * @param GitPlatformAdapter|null $adapter Platform adapter (auto-created from ApiClient if null) */ public function __construct( ApiClient $apiClient, AuditLogger $logger, MetricsCollector $metrics, ?CheckpointManager $checkpoints = null, ?DefinitionParser $definitionParser = null, ?GitPlatformAdapter $adapter = null ) { $this->adapter = $adapter ?? new MokoGiteaAdapter($apiClient); $this->logger = $logger; $this->metrics = $metrics; $this->checkpoints = $checkpoints ?? new CheckpointManager('.checkpoints'); $this->definitionParser = $definitionParser ?? new DefinitionParser(); $this->manifestParser = new MokoStandardsParser(); } /** * Get list of repositories for an organization * * @param string $org Organization name * @param bool $skipArchived Whether to skip archived repositories * @return array Array of repository information */ public function getRepositories(string $org, bool $skipArchived = false): array { $repos = $this->adapter->listOrgRepos($org, $skipArchived); $this->metrics->setGauge('repositories_found', count($repos)); return $repos; } /** * Check if repository has override file * * @param string $org Organization name * @param string $repo Repository name * @return bool True if override file exists */ public function hasOverrideFile(string $org, string $repo): bool { try { $overridePath = $this->adapter->getMetadataDir() . '/' . self::SYNC_OVERRIDE_FILE_SUFFIX; $override = $this->adapter->getFileContents($org, $repo, $overridePath); return $override !== ''; } catch (Exception $e) { return false; } } /** * Process single repository * * @param string $org Organization name * @param string $repo Repository name * @param bool $dryRun Whether to perform a dry run * @param bool $force Force update even if no changes * @return int|false PR number on success, false if skipped/failed * @throws SynchronizationNotImplementedException When synchronization logic is not implemented */ public function processRepository(string $org, string $repo, bool $dryRun = false, bool $force = false): int|false { $txn = $this->logger->startTransaction("process_repo_{$repo}"); try { // Check for override file if ($this->hasOverrideFile($org, $repo)) { $this->logger->logInfo("Repository {$repo} has override file, parsing configuration"); // Override file exists - in full implementation would parse it // For now, skip repos with overrides $this->metrics->increment('repos_with_overrides'); $txn->end('success'); return false; } if ($dryRun) { $this->logger->logInfo("DRY-RUN: Would update repository {$repo}"); $txn->end('success'); return 0; } // Execute synchronization $result = $this->synchronizeRepository($org, $repo, $force); if ($result !== false) { $this->metrics->increment('repos_synced'); $txn->end('success'); } else { $txn->end('failure'); } return $result; } catch (Exception $e) { $txn->end('failure'); $this->logger->logError("Failed to process repository {$repo}: " . $e->getMessage()); throw $e; } } /** * Synchronize files to a repository * * @param string $org Organization name * @param string $repo Repository name * @param bool $force Force override protected files * @return int|false PR number on success, false if skipped/failed */ private function synchronizeRepository(string $org, string $repo, bool $force): int|false { $this->logger->logInfo("Starting synchronization for {$org}/{$repo}"); // Resolve repo root (three levels up from this file: Enterprise/ → lib/ → root) // API repo root (definitions, sync code) $repoRoot = dirname(dirname(__DIR__)); // MokoStandards repo root (templates, configs) $standardsRoot = getenv('MOKOSTANDARDS_ROOT') ?: dirname($repoRoot) . '/MokoStandards'; // Detect platform from repo metadata $repoInfo = $this->adapter->getRepo($org, $repo); $platform = $this->detectPlatform($repoInfo); $this->logger->logInfo("Detected platform for {$repo}: {$platform}"); // Load file list from the Terraform definition for this platform $filesToSync = $this->definitionParser->parseForPlatform($platform, $repoRoot); // Append shared workflows — the parser can't extract them from nested // subdirectories blocks due to heredoc interference in .tf files. $sharedFiles = $this->getSharedWorkflows($platform, $repoRoot); // Deduplicate by destination — shared workflows take precedence over parser entries $seen = []; foreach ($filesToSync as $f) { $seen[$f['destination']] = true; } foreach ($sharedFiles as $f) { if (!isset($seen[$f['destination']])) { $filesToSync[] = $f; } } $defCount = count($filesToSync) - count($sharedFiles); $sharedAdded = count($filesToSync) - $defCount; $sharedTotal = count($sharedFiles); $this->logger->logInfo( "Loaded " . count($filesToSync) . " sync entries for {$platform}" . " (def={$defCount}, shared={$sharedAdded}/{$sharedTotal} added, " . ($sharedTotal - $sharedAdded) . " deduped)" ); // Log shared workflow destinations for debugging foreach ($sharedFiles as $sf) { $dest = $sf['destination'] ?? '?'; $added = !isset($seen[$dest]) ? 'ADDED' : 'DEDUPED'; $this->logger->logInfo(" shared: {$dest} [{$added}]"); } if (empty($filesToSync)) { $this->logger->logWarning("No syncable entries found in definition for platform '{$platform}', skipping {$repo}"); return false; } // Check if there's already a PR open for this repo. // With --force, proceed anyway — createSyncPR() will reset the branch // and update the existing PR body rather than creating a duplicate. $existingPR = $this->checkForExistingPR($org, $repo); if ($existingPR && !$force) { $this->logger->logInfo("PR #{$existingPR} already exists for {$repo}, skipping (use --force to re-sync)"); return false; } if ($existingPR && $force) { $this->logger->logInfo("PR #{$existingPR} already exists for {$repo} — force flag set, re-syncing"); } // Create PR with file updates driven by the definition // Use API repo root ($repoRoot) — templates live here, not in $standardsRoot $result = $this->createSyncPR($org, $repo, $platform, $filesToSync, $repoRoot, $force); $prNumber = $result['number'] ?? null; $summary = $result['summary'] ?? []; if ($prNumber) { $this->logger->logInfo("Successfully created PR #{$prNumber} for {$repo}"); // Generate / update definitions/sync/{repo}.def.tf AFTER the sync so it // reflects exactly what was pushed in this run. $this->generateRepositoryDefinition($org, $repo, $platform, $repoInfo, $summary); return (int) $prNumber; } return false; } /** * Check if there's already an open PR for sync */ private function checkForExistingPR(string $org, string $repo): ?int { try { $prs = $this->adapter->listPullRequests($org, $repo, [ 'state' => 'open', 'head' => "{$org}:" . self::SYNC_BRANCH, ]); if (!empty($prs) && is_array($prs)) { return $prs[0]['number'] ?? null; } } catch (Exception $e) { $this->logger->logWarning("Failed to check for existing PR: " . $e->getMessage()); } return null; } /** * Generate / update the repository tracking definition after a successful sync. * * Writes definitions/sync/{repo}.def.tf with: * - the base platform definition as a foundation * - a sync_record block recording what was actually pushed (files created/updated/skipped) * - full timestamps and platform metadata * * @param string $org * @param string $repo * @param string $platform Detected platform slug (e.g. 'dolibarr') * @param array $repoInfo Raw GitHub API repository object * @param array $summary Sync result from createSyncPR: {copied[], skipped[], total} * @return bool */ private function generateRepositoryDefinition( string $org, string $repo, string $platform, array $repoInfo, array $summary ): bool { try { $this->logger->logInfo("Writing sync tracking definition for {$org}/{$repo}"); $timestamp = date('c'); $description = addslashes($repoInfo['description'] ?? ''); $defaultBranch = $repoInfo['default_branch'] ?? 'main'; // Resolve repo root relative to this file's location $repoRoot = dirname(dirname(__DIR__)); $baseDefPath = "{$repoRoot}/definitions/default/{$platform}.tf"; if (!file_exists($baseDefPath)) { $baseDefPath = "{$repoRoot}/definitions/default/generic.tf"; } $baseDefinition = file_get_contents($baseDefPath) ?: ''; // Extract definition version from the source .tf metadata block $definitionVersion = 'unknown'; if (preg_match('/\bversion\s*=\s*"([^"]+)"/', $baseDefinition, $vm)) { $definitionVersion = $vm[1]; } // Cache the nullable sub-arrays once to avoid repeated null-coalescing $copiedItems = $summary['copied'] ?? []; $skippedItems = $summary['skipped'] ?? []; $totalCount = (int) ($summary['total'] ?? 0); // Build the synced_files list $syncedEntries = ''; foreach ($copiedItems as $item) { $action = addslashes($item['action'] ?? 'synced'); $file = addslashes($item['file'] ?? ''); $syncedEntries .= " { path = \"{$file}\" action = \"{$action}\" },\n"; } $skippedEntries = ''; foreach ($skippedItems as $item) { $file = addslashes($item['file'] ?? ''); $reason = addslashes($item['reason'] ?? ''); $skippedEntries .= " { path = \"{$file}\" reason = \"{$reason}\" },\n"; } $createdCount = count(array_filter($copiedItems, fn($i) => ($i['action'] ?? '') === 'created')); $updatedCount = count(array_filter($copiedItems, fn($i) => ($i['action'] ?? '') === 'updated')); $skippedCount = count($skippedItems); // Assemble the definition file using PHP 7.3+ flexible heredoc: // the closing marker is indented, so PHP strips that many leading spaces automatically. $definition = <<logger->logInfo("Wrote sync tracking definition: {$defFilePath}"); $this->metrics->increment('definitions_generated'); return true; } catch (Exception $e) { $this->logger->logError("Failed to write tracking definition for {$repo}: " . $e->getMessage()); return false; } } /** * Detect platform from repository info */ /** Repos that are the full Dolibarr platform, not individual modules. */ private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods']; /** * Detect platform from the .mokostandards manifest (authoritative), falling * back to name/topic/description heuristics when the manifest is missing or * unparseable. */ private function detectPlatform(array $repoInfo): string { $org = $repoInfo['full_name'] ? explode('/', $repoInfo['full_name'])[0] : ''; $name = $repoInfo['name'] ?? ''; // ── 1. Try reading the XML .mokostandards manifest ────────────��─ $manifestPlatform = $this->readManifestPlatform($org, $name); if ($manifestPlatform !== null) { $this->logger->logInfo("Platform for {$name} from .mokostandards manifest: {$manifestPlatform}"); return $manifestPlatform; } // ── 2. Fallback: heuristic detection ──────────────────────────── return $this->detectPlatformByHeuristics($repoInfo); } /** * Read the platform slug from the remote .mokostandards manifest. * Checks .mokogitea/.mokostandards, .github/.mokostandards, and root .mokostandards. * * @return string|null Platform slug or null if not found/parseable */ private function readManifestPlatform(string $org, string $repo): ?string { $metaDir = $this->adapter->getMetadataDir(); $paths = [ "{$metaDir}/.mokostandards", '.mokostandards', ]; if ($metaDir === '.mokogitea') { $paths[] = '.github/.mokostandards'; } foreach ($paths as $path) { try { $file = $this->adapter->getFileContents($org, $repo, $path); $content = base64_decode($file['content'] ?? ''); $platform = $this->manifestParser->extractPlatform($content); if ($platform !== null && in_array($platform, MokoStandardsParser::VALID_PLATFORMS, true)) { return $platform; } } catch (Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); } } return null; } /** * Heuristic platform detection from repo name, topics, and description. * Used as fallback when .mokostandards manifest is missing or unparseable. */ private function detectPlatformByHeuristics(array $repoInfo): string { $name = $repoInfo['name'] ?? ''; $nameLower = strtolower($name); $description = strtolower($repoInfo['description'] ?? ''); $topics = $repoInfo['topics'] ?? []; // Explicit platform repos — full Dolibarr installation, not a module if (in_array($name, self::CRM_PLATFORM_REPOS, true)) { return 'platform'; } if (in_array('dolibarr-platform', $topics)) { return 'platform'; } // Check topics first — templates before generic joomla if (in_array('joomla', $topics)) { return 'joomla'; } if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) { return 'joomla'; } if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) { return 'dolibarr'; } // Check name patterns — templates before generic joomla if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) { return 'joomla'; } if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) { return 'joomla'; } if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) { return 'dolibarr'; } // Check description patterns if ( str_contains($description, 'joomla template') || str_contains($description, 'joomla 5 template') || str_contains($description, 'joomla 4 template') ) { return 'joomla'; } if (str_contains($description, 'joomla') || str_contains($description, 'component')) { return 'joomla'; } if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) { return 'dolibarr'; } // Default return 'generic'; } /** * Create a PR with sync updates driven by the flat entry list from DefinitionParser. * * @param string $org * @param string $repo * @param string $platform Detected platform slug (e.g. 'dolibarr') * @param array $filesToSync * @param string $repoRoot Absolute path to the MokoStandards repository root * @param bool $force When true, overwrite files even when always_overwrite = false * @return array{number: ?int, summary: array} */ private function createSyncPR(string $org, string $repo, string $platform, array $filesToSync, string $repoRoot, bool $force): array { $nullResult = ['number' => null, 'summary' => []]; try { $repoInfo = $this->adapter->getRepo($org, $repo); $defaultBranch = $repoInfo['default_branch'] ?? 'main'; // Collect all branches to sync — default branch + any additional branches $branchesToSync = [$defaultBranch]; try { $allBranches = $this->adapter->listBranches($org, $repo); foreach ($allBranches as $branch) { $name = $branch['name'] ?? ''; if ($name !== '' && $name !== $defaultBranch) { $branchesToSync[] = $name; } } } catch (\Throwable $e) { $this->logger->logWarning("Could not list branches for {$repo}, syncing default only: " . $e->getMessage()); } $this->logger->logInfo( "Syncing files to {$org}/{$repo} across " . count($branchesToSync) . " branch(es): " . implode(', ', $branchesToSync) ); // Sync to each branch $combinedSummary = ['copied' => [], 'skipped' => [], 'total' => 0]; foreach ($branchesToSync as $branchName) { $this->logger->logInfo(" Syncing branch: {$branchName}"); $branchSummary = $this->syncFilesToBranch($org, $repo, $platform, $filesToSync, $repoRoot, $force, $branchName, null); // Merge summaries — only count first branch's copied files to avoid duplicates in tracking if ($branchName === $defaultBranch) { $combinedSummary = $branchSummary; } } $summary = $combinedSummary; // Ensure composer.json requires mokoconsulting-tech/enterprise (default branch only) $this->ensureComposerEnterprise($org, $repo, $defaultBranch, $summary); // Migrate .mokostandards to XML manifest (default branch only) $this->migrateMokoStandards($org, $repo, $defaultBranch, $platform, $repoInfo, $summary); if (count($summary['copied']) === 0) { $this->logger->logWarning("No files were created/updated for {$repo}"); return $nullResult; } // Create tracking issue (no PR — files pushed directly to default branch) $issueBody = $this->generatePRBody($summary); $issueTitle = 'chore: MokoStandards v' . self::STANDARDS_MINOR . ' sync — ' . count($summary['copied']) . ' files updated'; $issueNumber = null; try { $issueData = $this->adapter->createIssue($org, $repo, $issueTitle, $issueBody, [ 'labels' => ['mokostandards', 'type: chore', 'automation'], 'assignees' => ['jmiller'], ]); $issueNumber = $issueData['number'] ?? null; $this->logger->logInfo( "Created tracking issue #{$issueNumber} — " . count($summary['copied']) . " files synced directly to {$defaultBranch}" ); } catch (\Exception $e) { $this->logger->logWarning("Could not create tracking issue: " . $e->getMessage()); } return ['number' => $issueNumber, 'summary' => $summary]; } catch (CircuitBreakerOpen | RateLimitExceeded $e) { $this->logger->logError("Sync failed: " . $e->getMessage()); throw $e; } catch (Exception $e) { $this->logger->logError("Sync failed: " . $e->getMessage()); return $nullResult; } } /** * Replace all {{TOKEN}} placeholders in a template file with repo-specific values. * * Tokens sourced from GitHub API data (always available): * {{REPO_NAME}} — repository name * {{REPO_URL}} — full GitHub URL * {{REPO_DESCRIPTION}} — GitHub repo description * {{PRIMARY_LANGUAGE}} — dominant language from GitHub * {{PLATFORM_TYPE}} — human-readable platform label * * Dolibarr-specific tokens (dolibarr platform only): * {{MODULE_NAME}} — lowercase module name (e.g. mokocrm) * {{MODULE_CLASS}} — PascalCase class name (e.g. MokoCRM) * {{MODULE_ID}} — $this->numero from descriptor (null → left unreplaced) * * @param string $content Raw template content * @param string $repo Repository name * @param string $org Organisation name * @param string $platform Detected platform slug * @param array $repoInfo Raw GitHub API repository object * Merge a git config file (gitignore / gitattributes / ftp_ignore) by * ensuring all template lines are present without removing custom entries. * * Strategy: take the existing remote content, then append any template * lines that are missing. Comments and blank lines from the template are * included to preserve section structure. Duplicate non-blank lines are * never added. * * @param string $existing Current file content from the remote repo * @param string $template Template file content from MokoStandards * @return string Merged content */ /** * Return shared workflow entries that should be synced to all platforms. * * The .tf definition parser cannot extract workflows from nested * subdirectories blocks because heredoc content in CLAUDE.md disrupts * bracket matching. This method provides the workflow list directly. * * @return array */ /** * Ensure the remote composer.json requires mokoconsulting-tech/enterprise. * If the package is missing, add it and commit the change to the sync branch. */ /** * Sync files to a single branch. * * @param string $org Organization name * @param string $repo Repository name * @param string $platform Detected platform type * @param array $filesToSync Files to synchronize * @param string $repoRoot Path to MokoStandards root * @param bool $force Force overwrite * @param string $branchName Target branch * @param string|null $moduleId Dolibarr module ID (pre-fetched) * @return array Summary of operations */ private function syncFilesToBranch( string $org, string $repo, string $platform, array $filesToSync, string $repoRoot, bool $force, string $branchName, ?string $moduleId ): array { $repoInfo = $this->adapter->getRepo($org, $repo); $summary = ['copied' => [], 'skipped' => [], 'total' => 0]; foreach ($filesToSync as $entry) { $summary['total']++; $targetPath = $entry['destination']; $basename = strtolower(basename($targetPath)); $isReadme = $basename === 'readme.md'; $isChangelog = in_array($basename, ['changelog.md', 'changelog'], true); $isProtected = $isReadme || $isChangelog; // Protected files are NEVER overwritten, even with --force if ($entry['protected'] ?? false) { $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Protected — never overwritten']; continue; } $canOverwrite = !$isProtected && ($force || $entry['always_overwrite']); if ($isReadme) { $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'README — never overwritten']; continue; } if ($isChangelog) { $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'CHANGELOG — never overwritten']; continue; } if (isset($entry['inline_content'])) { $content = $entry['inline_content']; } else { $sourcePath = rtrim($repoRoot, '/') . '/' . ltrim($entry['source'] ?? '', '/'); if (!file_exists($sourcePath)) { $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Source file not found']; continue; } $content = file_get_contents($sourcePath); if ($content === false) { $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Failed to read source']; continue; } } $content = $this->processTemplateContent($content, $repo, $org, $platform, $repoInfo, $moduleId); try { $existingFile = $this->adapter->getFileContents($org, $repo, $targetPath, $branchName); if (!$canOverwrite) { $existingDecoded = base64_decode($existingFile['content'] ?? ''); $hasStaleTokens = (bool) preg_match('/\{\{[A-Z_a-z]+\}\}|\{[A-Z_]{4,}\}/', $existingDecoded); if (!$hasStaleTokens) { $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Preserved (always_overwrite=false)']; continue; } } $isGitConfig = in_array(basename($targetPath), ['.gitignore', '.gitattributes', '.ftpignore'], true); if ($isGitConfig) { $existingDecoded = base64_decode($existingFile['content'] ?? ''); $content = $this->mergeGitConfigFile($existingDecoded, $content); } $this->adapter->createOrUpdateFile( $org, $repo, $targetPath, $content, "chore: update {$targetPath} from MokoStandards", $existingFile['sha'] ?? null, $branchName ); $this->logger->logInfo("Updated: {$targetPath} ({$branchName})"); $summary['copied'][] = ['file' => $targetPath, 'action' => 'updated']; } catch (Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); try { $this->adapter->createOrUpdateFile( $org, $repo, $targetPath, $content, "chore: add {$targetPath} from MokoStandards", null, $branchName ); $this->logger->logInfo("Created: {$targetPath} ({$branchName})"); $summary['copied'][] = ['file' => $targetPath, 'action' => 'created']; } catch (Exception $e2) { if (str_contains($e2->getMessage(), "sha") || str_contains($e2->getMessage(), '422')) { try { $this->adapter->getApiClient()->resetCircuitBreaker(); $existing = $this->adapter->getFileContents($org, $repo, $targetPath, $branchName); $this->adapter->createOrUpdateFile( $org, $repo, $targetPath, $content, "chore: update {$targetPath} from MokoStandards", $existing['sha'] ?? null, $branchName ); $summary['copied'][] = ['file' => $targetPath, 'action' => 'updated']; } catch (Exception $e3) { $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'API error: ' . $e3->getMessage()]; $this->adapter->getApiClient()->resetCircuitBreaker(); } } else { $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'API error: ' . $e2->getMessage()]; } } } } return $summary; } /** * Migrate .mokostandards to the platform metadata dir (.gitea/ or .github/) * and convert legacy YAML-like format to the new XML manifest. * * Handles: * 1. Location migration: root or .github/ → .mokogitea/.mokostandards * 2. Format migration: legacy "platform: xxx" → XML manifest * 3. Update existing XML: refresh timestamp */ private function migrateMokoStandards( string $org, string $repo, string $branchName, string $platform, array $repoInfo, array &$summary ): void { $metaDir = $this->adapter->getMetadataDir(); $targetPath = "{$metaDir}/.mokostandards"; // ── Collect existing files from all legacy locations ───────── $legacySources = ['.mokostandards']; if ($metaDir === '.mokogitea') { $legacySources[] = '.github/.mokostandards'; } $legacyFiles = []; // path => ['content' => raw, 'sha' => sha] foreach ($legacySources as $path) { try { $file = $this->adapter->getFileContents($org, $repo, $path, $branchName); $legacyFiles[$path] = [ 'content' => base64_decode($file['content'] ?? ''), 'sha' => $file['sha'] ?? '', ]; } catch (Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); } } // Check if target already exists in metadata dir $existingTarget = null; try { $file = $this->adapter->getFileContents($org, $repo, $targetPath, $branchName); $existingTarget = [ 'content' => base64_decode($file['content'] ?? ''), 'sha' => $file['sha'] ?? '', ]; } catch (Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); } // ── Determine the best existing content to work from ──────── $currentContent = $existingTarget['content'] ?? null; if ($currentContent === null) { // Pick from legacy sources (first found) foreach ($legacyFiles as $data) { $currentContent = $data['content']; break; } } // ── Generate the new XML manifest ─────────────────────────── $xmlContent = $this->generateMokoStandardsXml( $org, $repo, $platform, $repoInfo, $currentContent ); // ── Write to target path ──────────────────────────────────── $targetSha = $existingTarget['sha'] ?? null; $isNew = $existingTarget === null; $needsUpdate = $isNew || $existingTarget['content'] !== $xmlContent; if ($needsUpdate) { $action = $isNew ? 'create' : 'update'; $commitMsg = $isNew ? "chore: add XML .mokostandards manifest to {$metaDir}/" : "chore: update .mokostandards manifest (XML format)"; try { $this->adapter->createOrUpdateFile( $org, $repo, $targetPath, $xmlContent, $commitMsg, $targetSha, $branchName ); $this->logger->logInfo(ucfirst($action) . "d XML .mokostandards → {$targetPath}"); $summary['copied'][] = ['file' => $targetPath, 'action' => "{$action}d (XML manifest)"]; } catch (Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); $this->logger->logWarning("Could not {$action} .mokostandards: " . $e->getMessage()); return; } } // ── Delete legacy source files ────────────────────────────── foreach ($legacyFiles as $path => $data) { if ($path === $targetPath || empty($data['sha'])) { continue; } try { $this->adapter->deleteFile( $org, $repo, $path, $data['sha'], "chore: remove legacy {$path} (replaced by {$targetPath})", $branchName ); $this->logger->logInfo("Deleted legacy {$path}"); } catch (Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); } } } /** * Generate an XML .mokostandards manifest for a repository. * * If existing content is valid XML, preserves user-edited sections * (build, deploy, scripts, overrides) and only refreshes governance metadata. * * @param string $org Organization name * @param string $repo Repository name * @param string $platform Detected platform slug * @param array $repoInfo Gitea API repo object * @param string|null $existingContent Current .mokostandards content (XML or legacy) * @return string Well-formed XML content */ private function generateMokoStandardsXml( string $org, string $repo, string $platform, array $repoInfo, ?string $existingContent ): string { $params = [ 'name' => $repoInfo['name'] ?? $repo, 'org' => $org, 'platform' => $platform, 'standards_version' => self::STANDARDS_VERSION, 'description' => $repoInfo['description'] ?? '', 'license' => 'GPL-3.0-or-later', 'topics' => $repoInfo['topics'] ?? [], 'language' => $repoInfo['language'] ?? MokoStandardsParser::platformLanguage($platform), 'package_type' => MokoStandardsParser::platformPackageType($platform), 'last_synced' => date('c'), ]; // If existing content is already valid XML, try to preserve user sections if ($existingContent !== null && str_contains($existingContent, 'manifestParser->parse($existingContent); // Preserve user-edited build, deploy, scripts, overrides by re-emitting // the existing XML with only governance fields refreshed. // For now, we use the simple generate() which creates identity + governance + build. // User-managed sections (deploy, scripts, overrides) are preserved by doing // a targeted replacement of governance fields in the existing XML. return $this->refreshGovernanceInXml( $existingContent, $platform, self::STANDARDS_VERSION, date('c') ); } catch (\RuntimeException $e) { // Existing XML is broken — regenerate from scratch $this->logger->logInfo("Existing .mokostandards XML invalid, regenerating: " . $e->getMessage()); } } return $this->manifestParser->generate($params); } /** * Refresh only the fields in an existing XML .mokostandards, * preserving all other sections (build, deploy, scripts, overrides). */ private function refreshGovernanceInXml( string $xml, string $platform, string $standardsVersion, string $lastSynced ): string { $dom = new \DOMDocument('1.0', 'UTF-8'); $dom->preserveWhiteSpace = true; $dom->formatOutput = true; if (!$dom->loadXML($xml)) { // If parsing fails, return as-is return $xml; } $xpath = new \DOMXPath($dom); $xpath->registerNamespace('m', MokoStandardsParser::NAMESPACE_URI); // Update $nodes = $xpath->query('//m:governance/m:platform'); if ($nodes->length > 0) { $nodes->item(0)->textContent = $platform; } // Update $nodes = $xpath->query('//m:governance/m:standards-version'); if ($nodes->length > 0) { $nodes->item(0)->textContent = $standardsVersion; } // Update or create $nodes = $xpath->query('//m:governance/m:last-synced'); if ($nodes->length > 0) { $nodes->item(0)->textContent = $lastSynced; } else { $govNodes = $xpath->query('//m:governance'); if ($govNodes->length > 0) { $lastSyncedEl = $dom->createElementNS( MokoStandardsParser::NAMESPACE_URI, 'last-synced' ); $lastSyncedEl->textContent = $lastSynced; $govNodes->item(0)->appendChild($lastSyncedEl); } } return $dom->saveXML(); } private function ensureComposerEnterprise(string $org, string $repo, string $branchName, array &$summary): void { try { $file = $this->adapter->getFileContents($org, $repo, 'composer.json', $branchName); } catch (Exception $e) { return; // No composer.json — skip } $content = base64_decode($file['content'] ?? ''); $json = json_decode($content, true); if (!is_array($json)) { return; } // Don't add self-referencing dependency — skip if this IS the enterprise package if (($json['name'] ?? '') === 'mokoconsulting-tech/enterprise') { return; } $expectedConstraint = 'dev-' . self::VERSION_BRANCH; // Check if enterprise package is already required with correct constraint $currentConstraint = $json['require']['mokoconsulting-tech/enterprise'] ?? $json['require-dev']['mokoconsulting-tech/enterprise'] ?? null; if ($currentConstraint === $expectedConstraint) { return; // Already correct } // Add or update the enterprise package to point to version branch $json['require'] = $json['require'] ?? []; $json['require']['mokoconsulting-tech/enterprise'] = $expectedConstraint; // Sort require keys for consistency ksort($json['require']); $newContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; try { $this->adapter->createOrUpdateFile( $org, $repo, 'composer.json', $newContent, 'chore: add mokoconsulting-tech/enterprise dependency', $file['sha'] ?? null, $branchName ); $this->logger->logInfo("Added mokoconsulting-tech/enterprise to composer.json"); $summary['copied'][] = ['file' => 'composer.json', 'action' => 'enterprise dependency added']; } catch (Exception $e) { $this->logger->logWarning("Could not update composer.json: " . $e->getMessage()); } } /** * Template repo mapping — canonical source for each platform's workflows. * The sync engine clones these at runtime to get the latest workflow files. */ private const TEMPLATE_REPOS = [ 'joomla' => 'MokoConsulting/MokoStandards-Template-Joomla', 'dolibarr' => 'MokoConsulting/MokoStandards-Template-Dolibarr', 'generic' => 'MokoConsulting/MokoStandards-Template-Generic', 'client' => 'MokoConsulting/MokoStandards-Template-Client', ]; private function getSharedWorkflows(string $platform, string $repoRoot): array { $wfDir = $this->adapter->getWorkflowDir(); // Determine which template repo to source from $templateType = match (true) { in_array($platform, ['dolibarr', 'platform']) => 'dolibarr', in_array($platform, ['joomla', 'joomla']) => 'joomla', str_starts_with($platform, 'client') => 'client', default => 'generic', }; // Clone template repo to tmp if not already cached $templateRepo = self::TEMPLATE_REPOS[$templateType]; $cacheDir = sys_get_temp_dir() . '/mokostandards-sync/' . basename($templateRepo); if (!is_dir($cacheDir)) { $gitUrl = $this->adapter->getCloneUrl($templateRepo); $this->logger->logInfo("Cloning template: {$templateRepo} → {$cacheDir}"); $cloneResult = $this->adapter->cloneRepo($templateRepo, $cacheDir, ['depth' => 1]); if (!$cloneResult) { throw new \RuntimeException("Failed to clone template repo: {$templateRepo}"); } } // Read all .yml files from the template's .gitea/workflows/ $sourceDir = "{$cacheDir}/.gitea/workflows"; $shared = []; if (is_dir($sourceDir)) { foreach (glob("{$sourceDir}/*.yml") as $file) { $basename = basename($file); $shared[] = [$file, "{$wfDir}/{$basename}"]; } } // CODEOWNERS — GitHub only; Gitea doesn't enforce it if ($this->adapter->getPlatformName() === 'github') { $shared[] = ['templates/github/CODEOWNERS', '.github/CODEOWNERS']; } // Platform-specific gitignore (merged, not replaced) $gitignoreMap = [ 'dolibarr' => 'templates/configs/gitignore.dolibarr', 'platform' => 'templates/configs/gitignore.dolibarr', 'joomla' => 'templates/configs/.gitignore.joomla', ]; $gitignoreTemplate = $gitignoreMap[$platform] ?? 'templates/configs/gitignore'; $shared[] = [$gitignoreTemplate, '.gitignore']; // Create TODO.md stub if it doesn't exist (gitignored after first commit) $entries[] = [ 'inline_content' => "# TODO\n\n> **Note:** This file is not tracked in " . "version control (.gitignore). It is for local task tracking " . "only.\n\n## Critical\n -\n\n## Normal\n -\n\n## Low\n -\n", 'destination' => 'TODO.md', 'always_overwrite' => false, ]; // Always create a custom/ subdirectory under the workflow dir with a README // so repos have a safe place for custom workflows that sync won't touch. $entries[] = [ 'inline_content' => "# Custom Workflows\n\nPlace repo-specific workflows here.\n\n" . "- **Never overwritten** by MokoStandards bulk sync\n" . "- **Never deleted** by the repository-cleanup workflow\n" . "- Safe for custom CI, notifications, or repo-specific automation\n\n" . "Synced workflows live in the parent `{$wfDir}/` directory.\n", 'destination' => "{$wfDir}/custom/README.md", 'always_overwrite' => false, ]; foreach ($shared as [$source, $dest]) { $fullSource = "{$repoRoot}/{$source}"; if (file_exists($fullSource)) { $entries[] = [ 'source' => $source, // relative — RepositorySynchronizer prepends repoRoot 'destination' => $dest, 'always_overwrite' => true, ]; } } // Create update.txt stub for Dolibarr repos (plain text version file) if ($platform === 'dolibarr') { $entries[] = [ 'inline_content' => '0.0.0', 'destination' => 'update.txt', 'always_overwrite' => false, ]; } return $entries; } /** * Required .gitignore entries that MUST exist in every governed repo. * The sync validates these exist (appending if missing) without * overwriting custom entries. Repos can add their own patterns freely. */ private const REQUIRED_GITIGNORE_ENTRIES = [ // Secrets & environment '.env', '.env.local', '.env.*.local', 'secrets/', '*.secrets.*', // Sublime Text project files '*.sublime-project', '*.sublime-workspace', '*.sublime-settings', // SFTP config (Sublime SFTP, VS Code SFTP, etc.) 'sftp-config*.json', 'sftp-config.json.template', 'sftp-settings.json', // IDE / editor '.idea/', '.vscode/*', '.claude/', '*.code-workspace', // OS cruft '.DS_Store', 'Thumbs.db', // Task tracking 'TODO.md', // Vendor / dependencies '/vendor/', 'node_modules/', // Logs '*.log', ]; /** * Validate that required .gitignore entries exist in a repo. * Returns array of missing entries, empty if all present. * * @param string $existingContent Current .gitignore content from repo * @return array Missing required entries */ public function validateGitignoreEntries(string $existingContent): array { $existingLines = array_map('trim', explode("\n", $existingContent)); $existingSet = []; foreach ($existingLines as $line) { if ($line !== '' && !str_starts_with($line, '#')) { $existingSet[$line] = true; } } $missing = []; foreach (self::REQUIRED_GITIGNORE_ENTRIES as $entry) { if (!isset($existingSet[$entry])) { $missing[] = $entry; } } return $missing; } private function mergeGitConfigFile(string $existing, string $template): string { $existingLines = array_map('rtrim', explode("\n", $existing)); $templateLines = array_map('rtrim', explode("\n", $template)); // Build a set of normalised non-blank, non-comment lines from the remote $existingSet = []; foreach ($existingLines as $line) { $trimmed = trim($line); if ($trimmed !== '' && !str_starts_with($trimmed, '#')) { $existingSet[$trimmed] = true; } } // Walk the template and collect lines that are missing from the remote $missing = []; $prevWasMissing = false; foreach ($templateLines as $line) { $trimmed = trim($line); // Blank or comment lines: include them if they precede a missing entry // (to preserve section headers). Buffer them and flush when a missing // non-blank line is found. if ($trimmed === '' || str_starts_with($trimmed, '#')) { if ($prevWasMissing) { $missing[] = $line; } continue; } if (!isset($existingSet[$trimmed])) { // If the previous line was not missing, add a separator + any // section header comments that precede this line in the template. if (!$prevWasMissing && !empty($missing)) { $missing[] = ''; } $missing[] = $line; $prevWasMissing = true; } else { $prevWasMissing = false; } } if (empty($missing)) { return $existing; // nothing to add } // Append missing lines with a clear separator $merged = rtrim($existing) . "\n\n" . "# ── MokoStandards sync (auto-appended) ────────────────────────────────\n" . implode("\n", $missing) . "\n"; return $merged; } /** * @param string|null $moduleId Pre-fetched Dolibarr module numero, or null * @return string Processed content */ private function processTemplateContent( string $content, string $repo, string $org = '', string $platform = '', array $repoInfo = [], ?string $moduleId = null ): string { // Strip .template suffix from workflow file references $content = str_replace('.yml.template', '.yml', $content); // Map platform slug to human-readable label $platformType = match ($platform) { 'dolibarr' => 'Dolibarr module', 'joomla' => 'Joomla extension', 'generic' => 'PHP library', default => ucfirst(str_replace('-', ' ', $platform)), }; // Derive Dolibarr module identifiers from the repository name $moduleName = strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $repo)); $moduleClass = $repo; // Repo name is the PascalCase class (e.g. MokoCRM) // Build replacement map — uppercase tokens take precedence; legacy lowercase kept for compat $map = [ // Uppercase tokens (used in CLAUDE.md / copilot-instructions templates) '{{REPO_NAME}}' => $repoInfo['name'] ?? $repo, '{{REPO_URL}}' => "https://github.com/{$org}/{$repo}", '{{REPO_DESCRIPTION}}' => $repoInfo['description'] ?? '', '{{PRIMARY_LANGUAGE}}' => $repoInfo['language'] ?? '', '{{PLATFORM_TYPE}}' => $platformType, '{{MODULE_NAME}}' => $moduleName, '{{MODULE_CLASS}}' => $moduleClass, '{{WORKFLOW_DIR}}' => $this->adapter->getWorkflowDir(), // Legacy lowercase tokens '{{repo_name}}' => $repoInfo['name'] ?? $repo, '{{repo_name_lower}}' => strtolower($repo), '{{org}}' => $org, '{{platform}}' => $platform, '{{standards_version}}' => self::STANDARDS_VERSION, '{{standards_minor}}' => self::STANDARDS_MINOR, '{{standards_branch}}' => self::VERSION_BRANCH, // Single-brace tokens — used by GitHub repository templates and older MokoStandards stubs '{REPO_NAME}' => $repoInfo['name'] ?? $repo, '{REPO_URL}' => "https://github.com/{$org}/{$repo}", '{REPO_DESCRIPTION}' => $repoInfo['description'] ?? '', '{PRIMARY_LANGUAGE}' => $repoInfo['language'] ?? '', '{PLATFORM_TYPE}' => $platformType, '{MODULE_NAME}' => $moduleName, '{MODULE_CLASS}' => $moduleClass, '{MODULE_ID}' => '', // overridden below when moduleId is available '{repo_name}' => $repoInfo['name'] ?? $repo, '{repo_name_lower}' => strtolower($repo), '{org}' => $org, ]; // Only replace {{MODULE_ID}} / {MODULE_ID} if we actually have the value; otherwise leave // the placeholder intact so the CLAUDE.md self-repair block can fill it in later. if ($moduleId !== null) { $map['{{MODULE_ID}}'] = $moduleId; $map['{MODULE_ID}'] = $moduleId; } else { // Remove the empty single-brace placeholder so it doesn't corrupt values unset($map['{MODULE_ID}']); } return strtr($content, $map); } /** * Fetch the Dolibarr module numero ($this->numero) from the module descriptor. * * Searches the repository tree for src/core/modules/mod*.class.php and extracts * the unique module number. Returns null if not found or on any API error. * * @param string $org GitHub organisation * @param string $repo Repository name * @return string|null Module ID string, or null if unavailable */ private function fetchModuleId(string $org, string $repo): ?string { try { $treeEntries = $this->adapter->getTree($org, $repo, 'HEAD', true); $paths = array_column($treeEntries, 'path'); $descriptors = array_values(array_filter( $paths, static fn(string $p): bool => (bool) preg_match('#src/core/modules/mod\w+\.class\.php$#', $p) )); if (empty($descriptors)) { return null; } $fileData = $this->adapter->getFileContents($org, $repo, $descriptors[0]); $content = base64_decode(str_replace(["\n", "\r"], '', $fileData['content'] ?? '')); if (preg_match('/\$this->numero\s*=\s*(\d+)/', $content, $m)) { return $m[1]; } } catch (\Exception $e) { $this->logger->logInfo("Could not fetch module ID for {$repo}: " . $e->getMessage()); } return null; } /** * Generate PR body text */ private function generatePRBody(array $summary): string { $body = "## MokoStandards Synchronization\n\n"; $body .= "This PR synchronizes workflows, configurations, and scripts from the MokoStandards repository.\n\n"; // Summary statistics $body .= "### Summary\n"; $body .= "- 🆕 **Created**: " . count(array_filter($summary['copied'], fn($i) => $i['action'] === 'created')) . " files\n"; $body .= "- 🔄 **Updated**: " . count(array_filter($summary['copied'], fn($i) => $i['action'] === 'updated')) . " files\n"; $body .= "- ⚠️ **Skipped**: " . count($summary['skipped']) . " files\n"; $body .= "- 📊 **Total**: " . $summary['total'] . " files processed\n\n"; // List copied files if (!empty($summary['copied'])) { $body .= "### Files Copied\n\n"; foreach ($summary['copied'] as $item) { $action = $item['action'] === 'created' ? '🆕' : '🔄'; $body .= "- {$action} `{$item['file']}`\n"; } $body .= "\n"; } // List skipped files if (!empty($summary['skipped'])) { $body .= "### Files Skipped\n\n"; foreach ($summary['skipped'] as $item) { $body .= "- ⚠️ `{$item['file']}` - {$item['reason']}\n"; } $body .= "\n"; } $body .= "### Review Notes\n"; $body .= "- Please review all changes carefully\n"; $body .= "- Ensure no custom configurations are overwritten\n"; $body .= "- Test workflows and scripts after merging\n"; $body .= "- Verify issue templates render correctly\n\n"; $body .= "---\n"; $body .= "*This PR was automatically generated by the MokoStandards bulk sync process.*\n"; return $body; } /** * Synchronize multiple repositories * * @param string $org Organization name * @param array $options Sync options (repo, skipArchived, dryRun, force) * @return array Sync results with statistics */ public function synchronize(string $org, array $options = []): array { $specificRepo = $options['repo'] ?? null; $skipArchived = $options['skipArchived'] ?? false; $dryRun = $options['dryRun'] ?? false; $force = $options['force'] ?? false; $txn = $this->logger->startTransaction('bulk_synchronize'); try { // Get list of repositories $repos = $this->getRepositories($org, $skipArchived); if ($specificRepo) { $repos = array_filter($repos, fn($repo) => $repo['name'] === $specificRepo); } $total = count($repos); $results = [ 'total' => $total, 'success' => 0, 'skipped' => 0, 'failed' => 0, 'repositories' => [], ]; foreach ($repos as $index => $repo) { $repoName = $repo['name']; $progress = $index + 1; try { $updated = $this->processRepository($org, $repoName, $dryRun, $force); if ($updated) { $results['success']++; $this->metrics->increment('repos_updated_total', 1, ['status' => 'success']); $results['repositories'][$repoName] = 'updated'; } else { $results['skipped']++; $this->metrics->increment('repos_updated_total', 1, ['status' => 'skipped']); $results['repositories'][$repoName] = 'skipped'; } } catch (Exception $e) { $results['failed']++; $this->metrics->increment('repos_updated_total', 1, ['status' => 'failed']); $results['repositories'][$repoName] = 'failed: ' . $e->getMessage(); } // Save checkpoint $this->checkpoints->saveCheckpoint('bulk_sync', [ 'processed' => $progress, 'total' => $total, 'results' => $results, ]); } $txn->end('success'); return $results; } catch (Exception $e) { $txn->end('failure'); throw $e; } } /** * Apply labels to a PR or issue, creating any that don't yet exist on the repo. * * @param string $org GitHub organisation * @param string $repo Repository name * @param int $number PR or issue number * @param list $labels Label names to apply */ public function applyLabels(string $org, string $repo, int $number, array $labels): void { // Ensure labels exist on the repo before applying $existingLabels = $this->adapter->listLabels($org, $repo); $existingNames = array_column($existingLabels, 'name'); foreach ($labels as $label) { if (!in_array($label, $existingNames, true)) { try { $this->adapter->createLabel( $org, $repo, $label, match ($label) { 'mokostandards' => 'B60205', 'type: chore' => 'FEF2C0', 'automation' => '8B4513', default => 'EDEDED', }, match ($label) { 'mokostandards' => 'MokoStandards compliance', 'type: chore' => 'Maintenance tasks', 'automation' => 'Automated processes or scripts', default => '', } ); } catch (\Exception $createEx) { /* already exists race — ignore */ } } } try { $this->adapter->addIssueLabels($org, $repo, $number, $labels); } catch (\Exception $e) { $this->logger->logInfo("Could not apply labels to #{$number}: " . $e->getMessage()); } } }