Files
moko-platform/lib/Enterprise/RepositorySynchronizer.php
T
Jonathan Miller 4f2d000f16 feat: sync to all branches, add listBranches, add ext-zip
- RepositorySynchronizer now syncs files to ALL branches (main + dev + any others)
- Extract syncFilesToBranch() method for per-branch file operations
- Add GiteaAdapter::listBranches() method
- Add ext-zip to composer.json require
- Fix Guzzle base_uri resolution (trailing slash + strip leading slash)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:11:47 -05:00

1230 lines
53 KiB
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.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 (two levels up from this file: Enterprise/ → lib/ → api/ → root)
$repoRoot = dirname(dirname(dirname(__DIR__)));
// 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, $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. '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(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 = <<<HCL
/**
* Repository Sync Tracking Definition: {$org}/{$repo}
*
* Auto-generated by MokoStandards bulk sync on {$timestamp}
* Platform : {$platform}
* Description: {$description}
*
* DO NOT EDIT MANUALLY — this file is regenerated on every successful sync.
* To change what gets synced, edit definitions/default/{$platform}.tf
* and re-run the bulk-repo-sync workflow.
*/
locals {
sync_record = {
metadata = {
repo = "{$org}/{$repo}"
default_branch = "{$defaultBranch}"
detected_platform = "{$platform}"
description = "{$description}"
sync_timestamp = "{$timestamp}"
source_repo = "mokoconsulting-tech/MokoStandards"
base_definition = "definitions/default/{$platform}.tf"
}
sync_stats = {
total_files = {$totalCount}
created_files = {$createdCount}
updated_files = {$updatedCount}
skipped_files = {$skippedCount}
}
synced_files = [
{$syncedEntries} ]
skipped_files = [
{$skippedEntries} ]
}
}
# ---- Base platform definition (reference copy) ----
{$baseDefinition}
HCL;
$defFilePath = "{$repoRoot}/" . self::SYNC_DEFINITION_DIR . "/{$repo}.def.tf";
if (!is_dir(dirname($defFilePath))) {
mkdir(dirname($defFilePath), 0755, true);
}
file_put_contents($defFilePath, $definition);
$this->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<int, array{source?: string, inline_content?: string, destination: string, always_overwrite: bool}> $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-moko'],
]);
$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<int, array{source: string, destination: string, always_overwrite: bool}>
*/
/**
* 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;
$canOverwrite = !$isProtected && ($force || $entry['always_overwrite']) && !($entry['protected'] ?? false);
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;
}
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<string> $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());
}
}
}