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>
This commit is contained in:
Jonathan Miller
2026-04-16 19:11:47 -05:00
parent 1ea4b4f042
commit 4f2d000f16
2 changed files with 150 additions and 125 deletions
+5
View File
@@ -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
+145 -125
View File
@@ -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/.