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
- 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>
1588 lines
63 KiB
PHP
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());
|
|
}
|
|
}
|
|
}
|