4f2d000f16
- 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>
1230 lines
53 KiB
PHP
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());
|
|
}
|
|
}
|
|
}
|