* * 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/MokoStandards-API * PATH: /lib/Enterprise/RepositorySynchronizer.php * VERSION: 04.06.00 * 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 ApiClient $apiClient; private GitPlatformAdapter $adapter; private AuditLogger $logger; private MetricsCollector $metrics; private CheckpointManager $checkpoints; private DefinitionParser $definitionParser; /** * 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->apiClient = $apiClient; $this->adapter = $adapter ?? new GiteaAdapter($apiClient); $this->logger = $logger; $this->metrics = $metrics; $this->checkpoints = $checkpoints ?? new CheckpointManager('.checkpoints'); $this->definitionParser = $definitionParser ?? new DefinitionParser(); } /** * 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 !empty($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; } } $this->logger->logInfo("Loaded " . count($filesToSync) . " sync entries from definition for {$platform}"); 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 $result = $this->createSyncPR($org, $repo, $platform, $filesToSync, $standardsRoot, $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. 'crm-module') * @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/default-repository.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']; private function detectPlatform(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 'crm-platform'; } if (in_array('dolibarr-platform', $topics)) { return 'crm-platform'; } // Check topics first — templates before generic joomla if (in_array('joomla-template', $topics)) { return 'joomla-template'; } if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) { return 'waas-component'; } if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) { return 'crm-module'; } // Check name patterns — templates before generic joomla if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) { return 'joomla-template'; } if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) { return 'waas-component'; } if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) { return 'crm-module'; } // Check description patterns if (str_contains($description, 'joomla template') || str_contains($description, 'joomla 5 template') || str_contains($description, 'joomla 4 template')) { return 'joomla-template'; } if (str_contains($description, 'joomla') || str_contains($description, 'component')) { return 'waas-component'; } if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) { return 'crm-module'; } // Default return 'default-repository'; } /** * 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. 'crm-module') * @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, $moduleId ?? 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 (default branch only) $this->migrateMokoStandards($org, $repo, $defaultBranch, $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 (crm-module 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 from repo root to .github/.mokostandards. * Deletes the root file after copying to .github/. */ private function migrateMokoStandards(string $org, string $repo, string $branchName, array &$summary): void { $metaDir = $this->adapter->getMetadataDir(); $targetPath = "{$metaDir}/.mokostandards"; // Check if .mokostandards exists in root try { $rootFile = $this->adapter->getFileContents($org, $repo, '.mokostandards', $branchName); } catch (Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); return; // Doesn't exist in root — nothing to migrate } // Check if already exists in metadata dir $existsInMetaDir = false; try { $this->adapter->getFileContents($org, $repo, $targetPath, $branchName); $existsInMetaDir = true; } catch (Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); } $content = base64_decode($rootFile['content'] ?? ''); $rootSha = $rootFile['sha'] ?? ''; if (!$existsInMetaDir) { // Copy to metadata dir try { $this->adapter->createOrUpdateFile( $org, $repo, $targetPath, $content, "chore: migrate .mokostandards to {$metaDir}/", null, $branchName ); $this->logger->logInfo("Migrated .mokostandards → {$targetPath}"); $summary['copied'][] = ['file' => $targetPath, 'action' => 'migrated from root']; } catch (Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); return; } } // Delete from root if (!empty($rootSha)) { try { $this->adapter->deleteFile( $org, $repo, '.mokostandards', $rootSha, "chore: remove .mokostandards from root (moved to {$metaDir}/)", $branchName ); $this->logger->logInfo("Deleted root .mokostandards"); } catch (Exception $e) { $this->adapter->getApiClient()->resetCircuitBreaker(); } } } 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; } $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()); } } private function getSharedWorkflows(string $platform, string $repoRoot): array { $root = rtrim($repoRoot, '/'); $wfDir = $this->adapter->getWorkflowDir(); $shared = [ ['templates/workflows/shared/enterprise-firewall-setup.yml.template', "{$wfDir}/enterprise-firewall-setup.yml"], ['templates/workflows/shared/sync-version-on-merge.yml.template', "{$wfDir}/sync-version-on-merge.yml"], ['templates/workflows/shared/repository-cleanup.yml.template', "{$wfDir}/repository-cleanup.yml"], ['templates/workflows/shared/auto-dev-issue.yml.template', "{$wfDir}/auto-dev-issue.yml"], ['templates/workflows/shared/branch-freeze.yml.template', "{$wfDir}/branch-freeze.yml"], ['templates/workflows/shared/auto-assign.yml.template', "{$wfDir}/auto-assign.yml"], ['templates/workflows/shared/changelog-validation.yml.template', "{$wfDir}/changelog-validation.yml"], ['.github/workflows/standards-compliance.yml', "{$wfDir}/standards-compliance.yml"], ]; // CodeQL is GitHub-only; on Gitea, Trivy replaces it if ($this->adapter->getPlatformName() === 'github') { $shared[] = ['.github/workflows/codeql-analysis.yml', "{$wfDir}/codeql-analysis.yml"]; } // Platform-specific workflows if ($platform === 'crm-module') { $shared[] = ['templates/workflows/shared/deploy-dev.yml.template', "{$wfDir}/deploy-dev.yml"]; $shared[] = ['templates/workflows/shared/deploy-demo.yml.template', "{$wfDir}/deploy-demo.yml"]; $shared[] = ['templates/workflows/dolibarr/auto-release.yml.template', "{$wfDir}/auto-release.yml"]; $shared[] = ['templates/workflows/dolibarr/ci-dolibarr.yml.template', "{$wfDir}/ci-dolibarr.yml"]; $shared[] = ['templates/workflows/dolibarr/publish-to-mokodolimods.yml.template', "{$wfDir}/publish-to-mokodolimods.yml"]; $shared[] = ['templates/workflows/dolibarr/repo_health.yml.template', "{$wfDir}/repo_health.yml"]; } elseif ($platform === 'crm-platform') { $shared[] = ['templates/workflows/shared/deploy-dev.yml.template', "{$wfDir}/deploy-dev.yml"]; $shared[] = ['templates/workflows/shared/deploy-demo.yml.template', "{$wfDir}/deploy-demo.yml"]; $shared[] = ['templates/workflows/dolibarr/auto-release.yml.template', "{$wfDir}/auto-release.yml"]; $shared[] = ['templates/workflows/dolibarr/ci-dolibarr.yml.template', "{$wfDir}/ci-dolibarr.yml"]; } elseif ($platform === 'waas-component' || $platform === 'joomla-template') { $shared[] = ['templates/workflows/joomla/auto-release.yml.template', "{$wfDir}/auto-release.yml"]; $shared[] = ['templates/workflows/joomla/update-server.yml.template', "{$wfDir}/update-server.yml"]; $shared[] = ['templates/workflows/joomla/ci-joomla.yml.template', "{$wfDir}/ci-joomla.yml"]; $shared[] = ['templates/workflows/joomla/repo_health.yml.template', "{$wfDir}/repo_health.yml"]; $shared[] = ['templates/workflows/joomla/deploy-manual.yml.template', "{$wfDir}/deploy-manual.yml"]; } else { $shared[] = ['templates/workflows/shared/deploy-dev.yml.template', "{$wfDir}/deploy-dev.yml"]; $shared[] = ['templates/workflows/shared/deploy-demo.yml.template', "{$wfDir}/deploy-demo.yml"]; $shared[] = ['templates/workflows/shared/auto-release.yml.template', "{$wfDir}/auto-release.yml"]; } // 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 = [ 'crm-module' => 'templates/configs/gitignore.dolibarr', 'crm-platform' => 'templates/configs/gitignore.dolibarr', 'waas-component' => 'templates/configs/.gitignore.joomla', 'joomla-template' => '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 = "{$root}/{$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 === 'crm-module') { $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) { 'crm-module' => 'Dolibarr module', 'waas-component' => 'Joomla extension', 'default-repository' => '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', ['status' => 'success']); $results['repositories'][$repoName] = 'updated'; } else { $results['skipped']++; $this->metrics->increment('repos_updated_total', ['status' => 'skipped']); $results['repositories'][$repoName] = 'skipped'; } } catch (Exception $e) { $results['failed']++; $this->metrics->increment('repos_updated_total', ['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()); } } }