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:
@@ -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
|
||||
|
||||
@@ -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/.
|
||||
|
||||
Reference in New Issue
Block a user