diff --git a/lib/Enterprise/GiteaAdapter.php b/lib/Enterprise/GiteaAdapter.php index 7bd5933..1fc5d00 100644 --- a/lib/Enterprise/GiteaAdapter.php +++ b/lib/Enterprise/GiteaAdapter.php @@ -91,6 +91,11 @@ class GiteaAdapter implements GitPlatformAdapter return "{$webBase}/{$org}/{$repo}/issues/{$number}"; } + public function listBranches(string $org, string $repo): array + { + return $this->paginateAll("/repos/{$org}/{$repo}/branches"); + } + public function getBranchWebUrl(string $org, string $repo, string $branch): string { // Gitea uses /src/branch/ (not /tree/) for web UI diff --git a/lib/Enterprise/RepositorySynchronizer.php b/lib/Enterprise/RepositorySynchronizer.php index 859f382..1bffeb4 100644 --- a/lib/Enterprise/RepositorySynchronizer.php +++ b/lib/Enterprise/RepositorySynchronizer.php @@ -458,137 +458,40 @@ HCL; try { $repoInfo = $this->adapter->getRepo($org, $repo); $defaultBranch = $repoInfo['default_branch'] ?? 'main'; - // Push directly to default branch — no sync branch, no PR - $branchName = $defaultBranch; - $this->logger->logInfo("Syncing files directly to {$org}/{$repo}:{$defaultBranch}"); - - $summary = ['copied' => [], 'skipped' => [], 'total' => 0]; - - // Pre-fetch Dolibarr module ID (one API call per repo, not per file) - $moduleId = ($platform === 'crm-module') ? $this->fetchModuleId($org, $repo) : null; - - foreach ($filesToSync as $entry) { - $summary['total']++; - $targetPath = $entry['destination']; - // README and CHANGELOG files are never overwritten regardless of flags or definition config. - // Case-insensitive: readme.md / README.md / ChangeLog.md / CHANGELOG.md etc. - $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) { - $this->logger->logInfo("Skipping README (protected by policy): {$targetPath}"); - $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'README — never overwritten']; - continue; - } - if ($isChangelog) { - $this->logger->logInfo("Skipping CHANGELOG (protected by policy): {$targetPath}"); - $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'CHANGELOG — never overwritten']; - continue; - } - - // Resolve content: prefer inline_content (stub_content heredoc), - // fall back to reading from the external template file (source path). - if (isset($entry['inline_content'])) { - $content = $entry['inline_content']; - } else { - $sourcePath = rtrim($repoRoot, '/') . '/' . ltrim($entry['source'] ?? '', '/'); - - if (!file_exists($sourcePath)) { - $this->logger->logWarning("Source not found: {$sourcePath}"); - $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Source file not found']; - continue; - } - - $content = file_get_contents($sourcePath); - if ($content === false) { - $this->logger->logWarning("Cannot read: {$sourcePath}"); - $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Failed to read source']; - continue; - } - } - - $content = $this->processTemplateContent($content, $repo, $org, $platform, $repoInfo, $moduleId ?? null); - - 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) { - $this->logger->logInfo("Skipping existing file (always_overwrite=false): {$targetPath}"); - $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Preserved (always_overwrite=false)']; - continue; - } - $this->logger->logInfo("Overwriting file with stale placeholders: {$targetPath}"); - } - - // .gitignore and .gitattributes: merge template lines into existing - // content instead of replacing — preserves custom entries added by the repo. - $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}"); - $summary['copied'][] = ['file' => $targetPath, 'action' => 'updated']; - - } catch (Exception $e) { - // File does not exist yet — create it. - // Reset circuit breaker so 404s from the GET don't block the create. - $this->adapter->getApiClient()->resetCircuitBreaker(); - try { - $this->adapter->createOrUpdateFile( - $org, $repo, $targetPath, $content, - "chore: add {$targetPath} from MokoStandards", - null, - $branchName - ); - $this->logger->logInfo("Created: {$targetPath}"); - $summary['copied'][] = ['file' => $targetPath, 'action' => 'created']; - } catch (Exception $e2) { - // 422 "sha wasn't supplied" = file already exists on sync branch - // (created earlier in this run). Fetch sha and retry as update. - 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 - ); - $this->logger->logInfo("Updated (retry): {$targetPath}"); - $summary['copied'][] = ['file' => $targetPath, 'action' => 'updated']; - } catch (Exception $e3) { - $this->logger->logError("Failed to update {$targetPath}: " . $e3->getMessage()); - $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'API error: ' . $e3->getMessage()]; - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - } else { - $this->logger->logError("Failed to create {$targetPath}: " . $e2->getMessage()); - $summary['skipped'][] = ['file' => $targetPath, 'reason' => 'API error: ' . $e2->getMessage()]; - } + // 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()); } - // Ensure composer.json requires mokoconsulting-tech/enterprise - $this->ensureComposerEnterprise($org, $repo, $branchName, $summary); + $this->logger->logInfo("Syncing files to {$org}/{$repo} across " . count($branchesToSync) . " branch(es): " . implode(', ', $branchesToSync)); - // Migrate .mokostandards from root to .github/ - $this->migrateMokoStandards($org, $repo, $branchName, $summary); + // 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}"); @@ -667,6 +570,123 @@ HCL; * 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/.