Files
moko-platform/lib/Enterprise/RepositorySynchronizer.php
Jonathan Miller cbb4d73df5
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 7s
Generic: Repo Health / Scripts governance (push) Successful in 7s
Generic: Repo Health / Repository health (push) Successful in 19s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Universal: PR Check / Validate PR (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Universal: PR Check / Build RC Package (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Successful in 15s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m19s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 59s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 5s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 58s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m1s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m0s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 49s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 51s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 1m2s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m7s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 7s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m9s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 55s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m0s
fix: PHPStan level 4 → 5 — fix 4 errors
- bulk_sync: remove redundant array_values on already-list array
- RepositorySynchronizer: fix metrics increment() — labels passed as
  2nd param (value) instead of 3rd (labels), was a real bug

PHPStan level 5: 0 errors.

Authored-by: Moko Consulting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-25 22:42:50 -05:00

1588 lines
63 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/moko-platform
* PATH: /lib/Enterprise/RepositorySynchronizer.php
* 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 GitPlatformAdapter $adapter;
private AuditLogger $logger;
private MetricsCollector $metrics;
private CheckpointManager $checkpoints;
private DefinitionParser $definitionParser;
private MokoStandardsParser $manifestParser;
/**
* 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->adapter = $adapter ?? new MokoGiteaAdapter($apiClient);
$this->logger = $logger;
$this->metrics = $metrics;
$this->checkpoints = $checkpoints ?? new CheckpointManager('.checkpoints');
$this->definitionParser = $definitionParser ?? new DefinitionParser();
$this->manifestParser = new MokoStandardsParser();
}
/**
* 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 $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 (three levels up from this file: Enterprise/ → lib/ → root)
// API repo root (definitions, sync code)
$repoRoot = dirname(dirname(__DIR__));
// MokoStandards repo root (templates, configs)
$standardsRoot = getenv('MOKOSTANDARDS_ROOT') ?: dirname($repoRoot) . '/MokoStandards';
// 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;
}
}
$defCount = count($filesToSync) - count($sharedFiles);
$sharedAdded = count($filesToSync) - $defCount;
$sharedTotal = count($sharedFiles);
$this->logger->logInfo(
"Loaded " . count($filesToSync) . " sync entries for {$platform}"
. " (def={$defCount}, shared={$sharedAdded}/{$sharedTotal} added, "
. ($sharedTotal - $sharedAdded) . " deduped)"
);
// Log shared workflow destinations for debugging
foreach ($sharedFiles as $sf) {
$dest = $sf['destination'] ?? '?';
$added = !isset($seen[$dest]) ? 'ADDED' : 'DEDUPED';
$this->logger->logInfo(" shared: {$dest} [{$added}]");
}
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
// Use API repo root ($repoRoot) — templates live here, not in $standardsRoot
$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. 'dolibarr')
* @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(__DIR__));
$baseDefPath = "{$repoRoot}/definitions/default/{$platform}.tf";
if (!file_exists($baseDefPath)) {
$baseDefPath = "{$repoRoot}/definitions/default/generic.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'];
/**
* Detect platform from the .mokostandards manifest (authoritative), falling
* back to name/topic/description heuristics when the manifest is missing or
* unparseable.
*/
private function detectPlatform(array $repoInfo): string
{
$org = $repoInfo['full_name'] ? explode('/', $repoInfo['full_name'])[0] : '';
$name = $repoInfo['name'] ?? '';
// ── 1. Try reading the XML .mokostandards manifest ────────────
$manifestPlatform = $this->readManifestPlatform($org, $name);
if ($manifestPlatform !== null) {
$this->logger->logInfo("Platform for {$name} from .mokostandards manifest: {$manifestPlatform}");
return $manifestPlatform;
}
// ── 2. Fallback: heuristic detection ────────────────────────────
return $this->detectPlatformByHeuristics($repoInfo);
}
/**
* Read the platform slug from the remote .mokostandards manifest.
* Checks .mokogitea/.mokostandards, .github/.mokostandards, and root .mokostandards.
*
* @return string|null Platform slug or null if not found/parseable
*/
private function readManifestPlatform(string $org, string $repo): ?string
{
$metaDir = $this->adapter->getMetadataDir();
$paths = [
"{$metaDir}/.mokostandards",
'.mokostandards',
];
if ($metaDir === '.mokogitea') {
$paths[] = '.github/.mokostandards';
}
foreach ($paths as $path) {
try {
$file = $this->adapter->getFileContents($org, $repo, $path);
$content = base64_decode($file['content'] ?? '');
$platform = $this->manifestParser->extractPlatform($content);
if ($platform !== null && in_array($platform, MokoStandardsParser::VALID_PLATFORMS, true)) {
return $platform;
}
} catch (Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
return null;
}
/**
* Heuristic platform detection from repo name, topics, and description.
* Used as fallback when .mokostandards manifest is missing or unparseable.
*/
private function detectPlatformByHeuristics(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 'platform';
}
if (in_array('dolibarr-platform', $topics)) {
return 'platform';
}
// Check topics first — templates before generic joomla
if (in_array('joomla', $topics)) {
return 'joomla';
}
if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) {
return 'joomla';
}
if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) {
return 'dolibarr';
}
// Check name patterns — templates before generic joomla
if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) {
return 'joomla';
}
if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) {
return 'joomla';
}
if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) {
return 'dolibarr';
}
// Check description patterns
if (
str_contains($description, 'joomla template') || str_contains($description, 'joomla 5 template')
|| str_contains($description, 'joomla 4 template')
) {
return 'joomla';
}
if (str_contains($description, 'joomla') || str_contains($description, 'component')) {
return 'joomla';
}
if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) {
return 'dolibarr';
}
// Default
return 'generic';
}
/**
* 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. 'dolibarr')
* @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, 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 to XML manifest (default branch only)
$this->migrateMokoStandards($org, $repo, $defaultBranch, $platform, $repoInfo, $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'],
]);
$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 (dolibarr 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;
// Protected files are NEVER overwritten, even with --force
if ($entry['protected'] ?? false) {
$summary['skipped'][] = ['file' => $targetPath, 'reason' => 'Protected — never overwritten'];
continue;
}
$canOverwrite = !$isProtected && ($force || $entry['always_overwrite']);
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 to the platform metadata dir (.gitea/ or .github/)
* and convert legacy YAML-like format to the new XML manifest.
*
* Handles:
* 1. Location migration: root or .github/ → .mokogitea/.mokostandards
* 2. Format migration: legacy "platform: xxx" → XML manifest
* 3. Update existing XML: refresh <governance><last-synced> timestamp
*/
private function migrateMokoStandards(
string $org,
string $repo,
string $branchName,
string $platform,
array $repoInfo,
array &$summary
): void {
$metaDir = $this->adapter->getMetadataDir();
$targetPath = "{$metaDir}/.mokostandards";
// ── Collect existing files from all legacy locations ─────────
$legacySources = ['.mokostandards'];
if ($metaDir === '.mokogitea') {
$legacySources[] = '.github/.mokostandards';
}
$legacyFiles = []; // path => ['content' => raw, 'sha' => sha]
foreach ($legacySources as $path) {
try {
$file = $this->adapter->getFileContents($org, $repo, $path, $branchName);
$legacyFiles[$path] = [
'content' => base64_decode($file['content'] ?? ''),
'sha' => $file['sha'] ?? '',
];
} catch (Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
// Check if target already exists in metadata dir
$existingTarget = null;
try {
$file = $this->adapter->getFileContents($org, $repo, $targetPath, $branchName);
$existingTarget = [
'content' => base64_decode($file['content'] ?? ''),
'sha' => $file['sha'] ?? '',
];
} catch (Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
// ── Determine the best existing content to work from ────────
$currentContent = $existingTarget['content'] ?? null;
if ($currentContent === null) {
// Pick from legacy sources (first found)
foreach ($legacyFiles as $data) {
$currentContent = $data['content'];
break;
}
}
// ── Generate the new XML manifest ───────────────────────────
$xmlContent = $this->generateMokoStandardsXml(
$org,
$repo,
$platform,
$repoInfo,
$currentContent
);
// ── Write to target path ────────────────────────────────────
$targetSha = $existingTarget['sha'] ?? null;
$isNew = $existingTarget === null;
$needsUpdate = $isNew || $existingTarget['content'] !== $xmlContent;
if ($needsUpdate) {
$action = $isNew ? 'create' : 'update';
$commitMsg = $isNew
? "chore: add XML .mokostandards manifest to {$metaDir}/"
: "chore: update .mokostandards manifest (XML format)";
try {
$this->adapter->createOrUpdateFile(
$org,
$repo,
$targetPath,
$xmlContent,
$commitMsg,
$targetSha,
$branchName
);
$this->logger->logInfo(ucfirst($action) . "d XML .mokostandards → {$targetPath}");
$summary['copied'][] = ['file' => $targetPath, 'action' => "{$action}d (XML manifest)"];
} catch (Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
$this->logger->logWarning("Could not {$action} .mokostandards: " . $e->getMessage());
return;
}
}
// ── Delete legacy source files ──────────────────────────────
foreach ($legacyFiles as $path => $data) {
if ($path === $targetPath || empty($data['sha'])) {
continue;
}
try {
$this->adapter->deleteFile(
$org,
$repo,
$path,
$data['sha'],
"chore: remove legacy {$path} (replaced by {$targetPath})",
$branchName
);
$this->logger->logInfo("Deleted legacy {$path}");
} catch (Exception $e) {
$this->adapter->getApiClient()->resetCircuitBreaker();
}
}
}
/**
* Generate an XML .mokostandards manifest for a repository.
*
* If existing content is valid XML, preserves user-edited sections
* (build, deploy, scripts, overrides) and only refreshes governance metadata.
*
* @param string $org Organization name
* @param string $repo Repository name
* @param string $platform Detected platform slug
* @param array $repoInfo Gitea API repo object
* @param string|null $existingContent Current .mokostandards content (XML or legacy)
* @return string Well-formed XML content
*/
private function generateMokoStandardsXml(
string $org,
string $repo,
string $platform,
array $repoInfo,
?string $existingContent
): string {
$params = [
'name' => $repoInfo['name'] ?? $repo,
'org' => $org,
'platform' => $platform,
'standards_version' => self::STANDARDS_VERSION,
'description' => $repoInfo['description'] ?? '',
'license' => 'GPL-3.0-or-later',
'topics' => $repoInfo['topics'] ?? [],
'language' => $repoInfo['language'] ?? MokoStandardsParser::platformLanguage($platform),
'package_type' => MokoStandardsParser::platformPackageType($platform),
'last_synced' => date('c'),
];
// If existing content is already valid XML, try to preserve user sections
if ($existingContent !== null && str_contains($existingContent, '<mokostandards')) {
try {
$existing = $this->manifestParser->parse($existingContent);
// Preserve user-edited build, deploy, scripts, overrides by re-emitting
// the existing XML with only governance fields refreshed.
// For now, we use the simple generate() which creates identity + governance + build.
// User-managed sections (deploy, scripts, overrides) are preserved by doing
// a targeted replacement of governance fields in the existing XML.
return $this->refreshGovernanceInXml(
$existingContent,
$platform,
self::STANDARDS_VERSION,
date('c')
);
} catch (\RuntimeException $e) {
// Existing XML is broken — regenerate from scratch
$this->logger->logInfo("Existing .mokostandards XML invalid, regenerating: " . $e->getMessage());
}
}
return $this->manifestParser->generate($params);
}
/**
* Refresh only the <governance> fields in an existing XML .mokostandards,
* preserving all other sections (build, deploy, scripts, overrides).
*/
private function refreshGovernanceInXml(
string $xml,
string $platform,
string $standardsVersion,
string $lastSynced
): string {
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = true;
$dom->formatOutput = true;
if (!$dom->loadXML($xml)) {
// If parsing fails, return as-is
return $xml;
}
$xpath = new \DOMXPath($dom);
$xpath->registerNamespace('m', MokoStandardsParser::NAMESPACE_URI);
// Update <platform>
$nodes = $xpath->query('//m:governance/m:platform');
if ($nodes->length > 0) {
$nodes->item(0)->textContent = $platform;
}
// Update <standards-version>
$nodes = $xpath->query('//m:governance/m:standards-version');
if ($nodes->length > 0) {
$nodes->item(0)->textContent = $standardsVersion;
}
// Update or create <last-synced>
$nodes = $xpath->query('//m:governance/m:last-synced');
if ($nodes->length > 0) {
$nodes->item(0)->textContent = $lastSynced;
} else {
$govNodes = $xpath->query('//m:governance');
if ($govNodes->length > 0) {
$lastSyncedEl = $dom->createElementNS(
MokoStandardsParser::NAMESPACE_URI,
'last-synced'
);
$lastSyncedEl->textContent = $lastSynced;
$govNodes->item(0)->appendChild($lastSyncedEl);
}
}
return $dom->saveXML();
}
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;
}
// Don't add self-referencing dependency — skip if this IS the enterprise package
if (($json['name'] ?? '') === 'mokoconsulting-tech/enterprise') {
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());
}
}
/**
* Template repo mapping — canonical source for each platform's workflows.
* The sync engine clones these at runtime to get the latest workflow files.
*/
private const TEMPLATE_REPOS = [
'joomla' => 'MokoConsulting/MokoStandards-Template-Joomla',
'dolibarr' => 'MokoConsulting/MokoStandards-Template-Dolibarr',
'generic' => 'MokoConsulting/MokoStandards-Template-Generic',
'client' => 'MokoConsulting/MokoStandards-Template-Client',
];
private function getSharedWorkflows(string $platform, string $repoRoot): array
{
$wfDir = $this->adapter->getWorkflowDir();
// Determine which template repo to source from
$templateType = match (true) {
in_array($platform, ['dolibarr', 'platform']) => 'dolibarr',
in_array($platform, ['joomla', 'joomla']) => 'joomla',
str_starts_with($platform, 'client') => 'client',
default => 'generic',
};
// Clone template repo to tmp if not already cached
$templateRepo = self::TEMPLATE_REPOS[$templateType];
$cacheDir = sys_get_temp_dir() . '/mokostandards-sync/' . basename($templateRepo);
if (!is_dir($cacheDir)) {
$gitUrl = $this->adapter->getCloneUrl($templateRepo);
$this->logger->logInfo("Cloning template: {$templateRepo}{$cacheDir}");
$cloneResult = $this->adapter->cloneRepo($templateRepo, $cacheDir, ['depth' => 1]);
if (!$cloneResult) {
throw new \RuntimeException("Failed to clone template repo: {$templateRepo}");
}
}
// Read all .yml files from the template's .gitea/workflows/
$sourceDir = "{$cacheDir}/.gitea/workflows";
$shared = [];
if (is_dir($sourceDir)) {
foreach (glob("{$sourceDir}/*.yml") as $file) {
$basename = basename($file);
$shared[] = [$file, "{$wfDir}/{$basename}"];
}
}
// 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 = [
'dolibarr' => 'templates/configs/gitignore.dolibarr',
'platform' => 'templates/configs/gitignore.dolibarr',
'joomla' => '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 = "{$repoRoot}/{$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 === 'dolibarr') {
$entries[] = [
'inline_content' => '0.0.0',
'destination' => 'update.txt',
'always_overwrite' => false,
];
}
return $entries;
}
/**
* Required .gitignore entries that MUST exist in every governed repo.
* The sync validates these exist (appending if missing) without
* overwriting custom entries. Repos can add their own patterns freely.
*/
private const REQUIRED_GITIGNORE_ENTRIES = [
// Secrets & environment
'.env',
'.env.local',
'.env.*.local',
'secrets/',
'*.secrets.*',
// Sublime Text project files
'*.sublime-project',
'*.sublime-workspace',
'*.sublime-settings',
// SFTP config (Sublime SFTP, VS Code SFTP, etc.)
'sftp-config*.json',
'sftp-config.json.template',
'sftp-settings.json',
// IDE / editor
'.idea/',
'.vscode/*',
'.claude/',
'*.code-workspace',
// OS cruft
'.DS_Store',
'Thumbs.db',
// Task tracking
'TODO.md',
// Vendor / dependencies
'/vendor/',
'node_modules/',
// Logs
'*.log',
];
/**
* Validate that required .gitignore entries exist in a repo.
* Returns array of missing entries, empty if all present.
*
* @param string $existingContent Current .gitignore content from repo
* @return array<string> Missing required entries
*/
public function validateGitignoreEntries(string $existingContent): array
{
$existingLines = array_map('trim', explode("\n", $existingContent));
$existingSet = [];
foreach ($existingLines as $line) {
if ($line !== '' && !str_starts_with($line, '#')) {
$existingSet[$line] = true;
}
}
$missing = [];
foreach (self::REQUIRED_GITIGNORE_ENTRIES as $entry) {
if (!isset($existingSet[$entry])) {
$missing[] = $entry;
}
}
return $missing;
}
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) {
'dolibarr' => 'Dolibarr module',
'joomla' => 'Joomla extension',
'generic' => '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', 1, ['status' => 'success']);
$results['repositories'][$repoName] = 'updated';
} else {
$results['skipped']++;
$this->metrics->increment('repos_updated_total', 1, ['status' => 'skipped']);
$results['repositories'][$repoName] = 'skipped';
}
} catch (Exception $e) {
$results['failed']++;
$this->metrics->increment('repos_updated_total', 1, ['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());
}
}
}