2026-04-13 06:12:04 +00:00
<? 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
2026-05-11 17:01:17 -05:00
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
2026-04-15 02:35:30 +00:00
* PATH: /lib/Enterprise/RepositorySynchronizer.php
2026-04-13 06:12:04 +00:00
* BRIEF: Repository synchronization enterprise library
*/
declare ( strict_types = 1 );
namespace MokoEnterprise ;
use Exception ;
use RuntimeException ;
/**
* Repository Synchronizer
*
* Enterprise library for synchronizing files across multiple repositories
* based on configuration and override files.
*/
class RepositorySynchronizer
{
2026-04-15 02:35:30 +00:00
private const SYNC_DEFINITION_DIR = 'definitions/sync' ;
2026-04-14 20:16:28 -05:00
/** Override file path — resolved at runtime via adapter's getMetadataDir(). */
private const SYNC_OVERRIDE_FILE_SUFFIX = 'override.tf' ;
2026-04-13 06:12:04 +00:00
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 ;
2026-05-02 17:41:28 -05:00
private ApiClient $apiClient ;
private GitPlatformAdapter $adapter ;
private AuditLogger $logger ;
private MetricsCollector $metrics ;
private CheckpointManager $checkpoints ;
private DefinitionParser $definitionParser ;
private MokoStandardsParser $manifestParser ;
2026-04-13 06:12:04 +00:00
/**
* Constructor
*
* @param ApiClient $apiClient Raw API client (kept for backward compatibility)
* @param AuditLogger $logger Audit logger
* @param MetricsCollector $metrics Metrics collector
* @param CheckpointManager|null $checkpoints Checkpoint manager
* @param DefinitionParser|null $definitionParser Definition parser
* @param GitPlatformAdapter|null $adapter Platform adapter (auto-created from ApiClient if null)
*/
public function __construct (
ApiClient $apiClient ,
AuditLogger $logger ,
MetricsCollector $metrics ,
? CheckpointManager $checkpoints = null ,
? DefinitionParser $definitionParser = null ,
? GitPlatformAdapter $adapter = null
) {
$this -> apiClient = $apiClient ;
2026-04-16 15:51:51 -05:00
$this -> adapter = $adapter ?? new GiteaAdapter ( $apiClient );
2026-04-13 06:12:04 +00:00
$this -> logger = $logger ;
$this -> metrics = $metrics ;
$this -> checkpoints = $checkpoints ?? new CheckpointManager ( '.checkpoints' );
$this -> definitionParser = $definitionParser ?? new DefinitionParser ();
2026-05-02 17:41:28 -05:00
$this -> manifestParser = new MokoStandardsParser ();
2026-04-13 06:12:04 +00:00
}
/**
* 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 {
2026-04-14 20:16:28 -05:00
$overridePath = $this -> adapter -> getMetadataDir () . '/' . self :: SYNC_OVERRIDE_FILE_SUFFIX ;
$override = $this -> adapter -> getFileContents ( $org , $repo , $overridePath );
2026-04-13 06:12:04 +00:00
return ! empty ( $override );
} catch ( Exception $e ) {
return false ;
}
}
/**
* Process single repository
*
* @param string $org Organization name
* @param string $repo Repository name
* @param bool $dryRun Whether to perform a dry run
* @param bool $force Force update even if no changes
* @return int|false PR number on success, false if skipped/failed
* @throws SynchronizationNotImplementedException When synchronization logic is not implemented
*/
public function processRepository ( string $org , string $repo , bool $dryRun = false , bool $force = false ) : int | false
{
$txn = $this -> logger -> startTransaction ( "process_repo_ { $repo } " );
try {
// Check for override file
if ( $this -> hasOverrideFile ( $org , $repo )) {
$this -> logger -> logInfo ( "Repository { $repo } has override file, parsing configuration" );
// Override file exists - in full implementation would parse it
// For now, skip repos with overrides
$this -> metrics -> increment ( 'repos_with_overrides' );
$txn -> end ( 'success' );
return false ;
}
if ( $dryRun ) {
$this -> logger -> logInfo ( "DRY-RUN: Would update repository { $repo } " );
$txn -> end ( 'success' );
return 0 ;
}
// Execute synchronization
$result = $this -> synchronizeRepository ( $org , $repo , $force );
if ( $result !== false ) {
$this -> metrics -> increment ( 'repos_synced' );
$txn -> end ( 'success' );
} else {
$txn -> end ( 'failure' );
}
return $result ;
} catch ( Exception $e ) {
$txn -> end ( 'failure' );
$this -> logger -> logError ( "Failed to process repository { $repo } : " . $e -> getMessage ());
throw $e ;
}
}
/**
* Synchronize files to a repository
*
* @param string $org Organization name
* @param string $repo Repository name
* @param bool $force Force override protected files
* @return int|false PR number on success, false if skipped/failed
*/
private function synchronizeRepository ( string $org , string $repo , bool $force ) : int | false
{
$this -> logger -> logInfo ( "Starting synchronization for { $org } / { $repo } " );
2026-04-18 19:18:22 -05:00
// Resolve repo root (three levels up from this file: Enterprise/ → lib/ → root)
2026-04-16 19:18:44 -05:00
// API repo root (definitions, sync code)
$repoRoot = dirname ( dirname ( __DIR__ ));
// MokoStandards repo root (templates, configs)
$standardsRoot = getenv ( 'MOKOSTANDARDS_ROOT' ) ?: dirname ( $repoRoot ) . '/MokoStandards' ;
2026-04-13 06:12:04 +00:00
// 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 ;
}
}
2026-04-26 15:54:43 -05:00
$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 } ]" );
}
2026-04-13 06:12:04 +00:00
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
2026-04-26 15:41:22 -05:00
// Use API repo root ($repoRoot) — templates live here, not in $standardsRoot
$result = $this -> createSyncPR ( $org , $repo , $platform , $filesToSync , $repoRoot , $force );
2026-04-13 06:12:04 +00:00
$prNumber = $result [ 'number' ] ?? null ;
$summary = $result [ 'summary' ] ?? [];
if ( $prNumber ) {
$this -> logger -> logInfo ( "Successfully created PR # { $prNumber } for { $repo } " );
2026-04-15 02:35:30 +00:00
// Generate / update definitions/sync/{repo}.def.tf AFTER the sync so it
2026-04-13 06:12:04 +00:00
// 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.
*
2026-04-15 02:35:30 +00:00
* Writes definitions/sync/{repo}.def.tf with:
2026-04-13 06:12:04 +00:00
* - 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
2026-05-05 15:29:57 -05:00
* @param string $platform Detected platform slug (e.g. 'dolibarr')
2026-04-13 06:12:04 +00:00
* @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
2026-04-16 19:18:44 -05:00
$repoRoot = dirname ( dirname ( __DIR__ ));
2026-04-15 02:35:30 +00:00
$baseDefPath = " { $repoRoot } /definitions/default/ { $platform } .tf" ;
2026-04-13 06:12:04 +00:00
if ( ! file_exists ( $baseDefPath )) {
2026-05-05 15:29:57 -05:00
$baseDefPath = " { $repoRoot } /definitions/default/generic.tf" ;
2026-04-13 06:12:04 +00:00
}
$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.
2026-04-15 02:35:30 +00:00
* To change what gets synced, edit definitions/default/{$platform}.tf
2026-04-13 06:12:04 +00:00
* 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"
2026-04-15 02:35:30 +00:00
base_definition = "definitions/default/{$platform}.tf"
2026-04-13 06:12:04 +00:00
}
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' ];
2026-05-02 17:41:28 -05:00
/**
* Detect platform from the .mokostandards manifest (authoritative), falling
* back to name/topic/description heuristics when the manifest is missing or
* unparseable.
*/
2026-04-13 06:12:04 +00:00
private function detectPlatform ( array $repoInfo ) : string
2026-05-02 17:41:28 -05:00
{
$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 .gitea/.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 === '.gitea' ) {
$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
2026-04-13 06:12:04 +00:00
{
$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 )) {
2026-05-05 15:29:57 -05:00
return 'platform' ;
2026-04-13 06:12:04 +00:00
}
if ( in_array ( 'dolibarr-platform' , $topics )) {
2026-05-05 15:29:57 -05:00
return 'platform' ;
2026-04-13 06:12:04 +00:00
}
2026-04-14 20:16:28 -05:00
// Check topics first — templates before generic joomla
2026-05-05 15:29:57 -05:00
if ( in_array ( 'joomla' , $topics )) {
return 'joomla' ;
2026-04-14 20:16:28 -05:00
}
2026-04-13 06:12:04 +00:00
if ( in_array ( 'joomla' , $topics ) || in_array ( 'joomla-extension' , $topics )) {
2026-05-05 15:29:57 -05:00
return 'joomla' ;
2026-04-13 06:12:04 +00:00
}
if ( in_array ( 'dolibarr' , $topics ) || in_array ( 'dolibarr-module' , $topics )) {
2026-05-05 15:29:57 -05:00
return 'dolibarr' ;
2026-04-13 06:12:04 +00:00
}
2026-04-14 20:16:28 -05:00
// Check name patterns — templates before generic joomla
if ( str_contains ( $nameLower , 'template' ) && ( str_contains ( $nameLower , 'joomla' ) || str_contains ( $nameLower , 'tpl' ))) {
2026-05-05 15:29:57 -05:00
return 'joomla' ;
2026-04-14 20:16:28 -05:00
}
2026-04-13 06:12:04 +00:00
if ( str_contains ( $nameLower , 'joomla' ) || str_contains ( $nameLower , 'waas' )) {
2026-05-05 15:29:57 -05:00
return 'joomla' ;
2026-04-13 06:12:04 +00:00
}
if ( str_contains ( $nameLower , 'doli' ) || str_contains ( $nameLower , 'crm' )) {
2026-05-05 15:29:57 -05:00
return 'dolibarr' ;
2026-04-13 06:12:04 +00:00
}
// Check description patterns
2026-04-14 20:16:28 -05:00
if ( str_contains ( $description , 'joomla template' ) || str_contains ( $description , 'joomla 5 template' )
|| str_contains ( $description , 'joomla 4 template' )) {
2026-05-05 15:29:57 -05:00
return 'joomla' ;
2026-04-14 20:16:28 -05:00
}
2026-04-13 06:12:04 +00:00
if ( str_contains ( $description , 'joomla' ) || str_contains ( $description , 'component' )) {
2026-05-05 15:29:57 -05:00
return 'joomla' ;
2026-04-13 06:12:04 +00:00
}
if ( str_contains ( $description , 'dolibarr' ) || str_contains ( $description , 'module' )) {
2026-05-05 15:29:57 -05:00
return 'dolibarr' ;
2026-04-13 06:12:04 +00:00
}
2026-05-02 17:41:28 -05:00
2026-04-13 06:12:04 +00:00
// Default
2026-05-05 15:29:57 -05:00
return 'generic' ;
2026-04-13 06:12:04 +00:00
}
/**
* Create a PR with sync updates driven by the flat entry list from DefinitionParser.
*
* @param string $org
* @param string $repo
2026-05-05 15:29:57 -05:00
* @param string $platform Detected platform slug (e.g. 'dolibarr')
2026-04-13 06:12:04 +00:00
* @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' ;
2026-04-16 19:11:47 -05:00
// 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 ;
2026-04-13 06:12:04 +00:00
}
}
2026-04-16 19:11:47 -05:00
} catch ( \Throwable $e ) {
$this -> logger -> logWarning ( "Could not list branches for { $repo } , syncing default only: " . $e -> getMessage ());
}
2026-04-13 06:12:04 +00:00
2026-04-16 19:11:47 -05:00
$this -> logger -> logInfo ( "Syncing files to { $org } / { $repo } across " . count ( $branchesToSync ) . " branch(es): " . implode ( ', ' , $branchesToSync ));
2026-04-13 06:12:04 +00:00
2026-04-16 19:11:47 -05:00
// Sync to each branch
$combinedSummary = [ 'copied' => [], 'skipped' => [], 'total' => 0 ];
foreach ( $branchesToSync as $branchName ) {
$this -> logger -> logInfo ( " Syncing branch: { $branchName } " );
$branchSummary = $this -> syncFilesToBranch ( $org , $repo , $platform , $filesToSync , $repoRoot , $force , $branchName , $moduleId ?? null );
// Merge summaries — only count first branch's copied files to avoid duplicates in tracking
if ( $branchName === $defaultBranch ) {
$combinedSummary = $branchSummary ;
2026-04-13 06:12:04 +00:00
}
}
2026-04-16 19:11:47 -05:00
$summary = $combinedSummary ;
2026-04-13 06:12:04 +00:00
2026-04-16 19:11:47 -05:00
// Ensure composer.json requires mokoconsulting-tech/enterprise (default branch only)
$this -> ensureComposerEnterprise ( $org , $repo , $defaultBranch , $summary );
2026-04-13 06:12:04 +00:00
2026-05-02 17:41:28 -05:00
// Migrate .mokostandards to XML manifest (default branch only)
$this -> migrateMokoStandards ( $org , $repo , $defaultBranch , $platform , $repoInfo , $summary );
2026-04-13 06:12:04 +00:00
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' ],
2026-04-19 12:45:23 -05:00
'assignees' => [ 'jmiller' ],
2026-04-13 06:12:04 +00:00
]);
$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
*
2026-05-05 15:29:57 -05:00
* Dolibarr-specific tokens (dolibarr platform only):
2026-04-13 06:12:04 +00:00
* {{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.
*/
2026-04-16 19:11:47 -05:00
/**
* 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 ;
2026-04-19 14:10:14 -05:00
// 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' ]);
2026-04-16 19:11:47 -05:00
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 ;
}
2026-04-13 06:12:04 +00:00
/**
2026-05-02 17:41:28 -05:00
* 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/ → .gitea/.mokostandards
* 2. Format migration: legacy "platform: xxx" → XML manifest
* 3. Update existing XML: refresh <governance><last-synced> timestamp
2026-04-13 06:12:04 +00:00
*/
2026-05-02 17:41:28 -05:00
private function migrateMokoStandards (
string $org ,
string $repo ,
string $branchName ,
string $platform ,
array $repoInfo ,
array & $summary
) : void {
$metaDir = $this -> adapter -> getMetadataDir ();
2026-04-14 20:16:28 -05:00
$targetPath = " { $metaDir } /.mokostandards" ;
2026-05-02 17:41:28 -05:00
// ── Collect existing files from all legacy locations ─────────
$legacySources = [ '.mokostandards' ];
2026-04-26 22:47:01 -05:00
if ( $metaDir === '.gitea' ) {
2026-05-02 17:41:28 -05:00
$legacySources [] = '.github/.mokostandards' ;
2026-04-26 22:47:01 -05:00
}
2026-05-02 17:41:28 -05:00
$legacyFiles = []; // path => ['content' => raw, 'sha' => sha]
foreach ( $legacySources as $path ) {
2026-04-26 22:47:01 -05:00
try {
2026-05-02 17:41:28 -05:00
$file = $this -> adapter -> getFileContents ( $org , $repo , $path , $branchName );
$legacyFiles [ $path ] = [
'content' => base64_decode ( $file [ 'content' ] ?? '' ),
'sha' => $file [ 'sha' ] ?? '' ,
];
2026-04-26 22:47:01 -05:00
} catch ( Exception $e ) {
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
}
}
2026-05-02 17:41:28 -05:00
// Check if target already exists in metadata dir
$existingTarget = null ;
2026-04-13 06:12:04 +00:00
try {
2026-05-02 17:41:28 -05:00
$file = $this -> adapter -> getFileContents ( $org , $repo , $targetPath , $branchName );
$existingTarget = [
'content' => base64_decode ( $file [ 'content' ] ?? '' ),
'sha' => $file [ 'sha' ] ?? '' ,
];
2026-04-13 06:12:04 +00:00
} catch ( Exception $e ) {
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
}
2026-05-02 17:41:28 -05:00
// ── 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)" ;
2026-04-13 06:12:04 +00:00
try {
$this -> adapter -> createOrUpdateFile (
2026-05-02 17:41:28 -05:00
$org , $repo , $targetPath , $xmlContent ,
$commitMsg , $targetSha , $branchName
2026-04-13 06:12:04 +00:00
);
2026-05-02 17:41:28 -05:00
$this -> logger -> logInfo ( ucfirst ( $action ) . "d XML .mokostandards → { $targetPath } " );
$summary [ 'copied' ][] = [ 'file' => $targetPath , 'action' => " { $action } d (XML manifest)" ];
2026-04-13 06:12:04 +00:00
} catch ( Exception $e ) {
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
2026-05-02 17:41:28 -05:00
$this -> logger -> logWarning ( "Could not { $action } .mokostandards: " . $e -> getMessage ());
2026-04-13 06:12:04 +00:00
return ;
}
}
2026-05-02 17:41:28 -05:00
// ── Delete legacy source files ──────────────────────────────
foreach ( $legacyFiles as $path => $data ) {
if ( $path === $targetPath || empty ( $data [ 'sha' ])) {
continue ;
}
2026-04-13 06:12:04 +00:00
try {
$this -> adapter -> deleteFile (
2026-05-02 17:41:28 -05:00
$org , $repo , $path , $data [ 'sha' ],
"chore: remove legacy { $path } (replaced by { $targetPath } )" ,
2026-04-13 06:12:04 +00:00
$branchName
);
2026-05-02 17:41:28 -05:00
$this -> logger -> logInfo ( "Deleted legacy { $path } " );
2026-04-13 06:12:04 +00:00
} catch ( Exception $e ) {
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
}
}
}
2026-05-02 17:41:28 -05:00
/**
* 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 ();
}
2026-04-13 06:12:04 +00:00
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 ;
}
2026-04-26 11:55:10 -05:00
// Don't add self-referencing dependency — skip if this IS the enterprise package
if (( $json [ 'name' ] ?? '' ) === 'mokoconsulting-tech/enterprise' ) {
return ;
}
2026-04-13 06:12:04 +00:00
$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 ());
}
}
2026-05-02 18:17:53 -05:00
/**
* 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' ,
];
2026-04-13 06:12:04 +00:00
private function getSharedWorkflows ( string $platform , string $repoRoot ) : array
{
$wfDir = $this -> adapter -> getWorkflowDir ();
2026-05-02 18:17:53 -05:00
// Determine which template repo to source from
$templateType = match ( true ) {
2026-05-05 15:29:57 -05:00
in_array ( $platform , [ 'dolibarr' , 'platform' ]) => 'dolibarr' ,
in_array ( $platform , [ 'joomla' , 'joomla' ]) => 'joomla' ,
2026-05-02 18:17:53 -05:00
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 } " );
}
}
2026-04-13 06:12:04 +00:00
2026-05-02 18:17:53 -05:00
// 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 } " ];
}
2026-04-13 06:12:04 +00:00
}
// 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 = [
2026-05-05 15:29:57 -05:00
'dolibarr' => 'templates/configs/gitignore.dolibarr' ,
'platform' => 'templates/configs/gitignore.dolibarr' ,
'joomla' => 'templates/configs/.gitignore.joomla' ,
'joomla' => 'templates/configs/.gitignore.joomla' ,
2026-04-13 06:12:04 +00:00
];
$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.
2026-04-25 00:16:25 -05:00
$entries [] = [
2026-04-13 06:12:04 +00:00
'inline_content' => "# Custom Workflows \n\n Place repo-specific workflows here. \n\n "
. "- **Never overwritten** by MokoStandards bulk sync \n "
. "- **Never deleted** by the repository-cleanup workflow \n "
. "- Safe for custom CI, notifications, or repo-specific automation \n\n "
. "Synced workflows live in the parent ` { $wfDir } /` directory. \n " ,
'destination' => " { $wfDir } /custom/README.md" ,
'always_overwrite' => false ,
];
foreach ( $shared as [ $source , $dest ]) {
$fullSource = " { $root } / { $source } " ;
if ( file_exists ( $fullSource )) {
$entries [] = [
'source' => $source , // relative — RepositorySynchronizer prepends repoRoot
'destination' => $dest ,
'always_overwrite' => true ,
];
}
}
// Create update.txt stub for Dolibarr repos (plain text version file)
2026-05-05 15:29:57 -05:00
if ( $platform === 'dolibarr' ) {
2026-04-13 06:12:04 +00:00
$entries [] = [
'inline_content' => '0.0.0' ,
'destination' => 'update.txt' ,
'always_overwrite' => false ,
];
}
return $entries ;
}
2026-04-18 18:17:24 -05:00
/**
* 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 ;
}
2026-04-13 06:12:04 +00:00
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 ) {
2026-05-05 15:29:57 -05:00
'dolibarr' => 'Dolibarr module' ,
'joomla' => 'Joomla extension' ,
'generic' => 'PHP library' ,
2026-04-13 06:12:04 +00:00
default => ucfirst ( str_replace ( '-' , ' ' , $platform )),
};
// Derive Dolibarr module identifiers from the repository name
$moduleName = strtolower ( preg_replace ( '/[^a-zA-Z0-9]/' , '' , $repo ));
$moduleClass = $repo ; // Repo name is the PascalCase class (e.g. MokoCRM)
// Build replacement map — uppercase tokens take precedence; legacy lowercase kept for compat
$map = [
// Uppercase tokens (used in CLAUDE.md / copilot-instructions templates)
'{{REPO_NAME}}' => $repoInfo [ 'name' ] ?? $repo ,
'{{REPO_URL}}' => "https://github.com/ { $org } / { $repo } " ,
'{{REPO_DESCRIPTION}}' => $repoInfo [ 'description' ] ?? '' ,
'{{PRIMARY_LANGUAGE}}' => $repoInfo [ 'language' ] ?? '' ,
'{{PLATFORM_TYPE}}' => $platformType ,
'{{MODULE_NAME}}' => $moduleName ,
'{{MODULE_CLASS}}' => $moduleClass ,
'{{WORKFLOW_DIR}}' => $this -> adapter -> getWorkflowDir (),
// Legacy lowercase tokens
'{{repo_name}}' => $repoInfo [ 'name' ] ?? $repo ,
'{{repo_name_lower}}' => strtolower ( $repo ),
'{{org}}' => $org ,
'{{platform}}' => $platform ,
'{{standards_version}}' => self :: STANDARDS_VERSION ,
'{{standards_minor}}' => self :: STANDARDS_MINOR ,
'{{standards_branch}}' => self :: VERSION_BRANCH ,
// Single-brace tokens — used by GitHub repository templates and older MokoStandards stubs
'{REPO_NAME}' => $repoInfo [ 'name' ] ?? $repo ,
'{REPO_URL}' => "https://github.com/ { $org } / { $repo } " ,
'{REPO_DESCRIPTION}' => $repoInfo [ 'description' ] ?? '' ,
'{PRIMARY_LANGUAGE}' => $repoInfo [ 'language' ] ?? '' ,
'{PLATFORM_TYPE}' => $platformType ,
'{MODULE_NAME}' => $moduleName ,
'{MODULE_CLASS}' => $moduleClass ,
'{MODULE_ID}' => '' , // overridden below when moduleId is available
'{repo_name}' => $repoInfo [ 'name' ] ?? $repo ,
'{repo_name_lower}' => strtolower ( $repo ),
'{org}' => $org ,
];
// Only replace {{MODULE_ID}} / {MODULE_ID} if we actually have the value; otherwise leave
// the placeholder intact so the CLAUDE.md self-repair block can fill it in later.
if ( $moduleId !== null ) {
$map [ '{{MODULE_ID}}' ] = $moduleId ;
$map [ '{MODULE_ID}' ] = $moduleId ;
} else {
// Remove the empty single-brace placeholder so it doesn't corrupt values
unset ( $map [ '{MODULE_ID}' ]);
}
return strtr ( $content , $map );
}
/**
* Fetch the Dolibarr module numero ($this->numero) from the module descriptor.
*
* Searches the repository tree for src/core/modules/mod*.class.php and extracts
* the unique module number. Returns null if not found or on any API error.
*
* @param string $org GitHub organisation
* @param string $repo Repository name
* @return string|null Module ID string, or null if unavailable
*/
private function fetchModuleId ( string $org , string $repo ) : ? string
{
try {
$treeEntries = $this -> adapter -> getTree ( $org , $repo , 'HEAD' , true );
$paths = array_column ( $treeEntries , 'path' );
$descriptors = array_values ( array_filter (
$paths ,
static fn ( string $p ) : bool => ( bool ) preg_match ( '#src/core/modules/mod\w+\.class\.php$#' , $p )
));
if ( empty ( $descriptors )) {
return null ;
}
$fileData = $this -> adapter -> getFileContents ( $org , $repo , $descriptors [ 0 ]);
$content = base64_decode ( str_replace ([ " \n " , " \r " ], '' , $fileData [ 'content' ] ?? '' ));
if ( preg_match ( '/\$this->numero\s*=\s*(\d+)/' , $content , $m )) {
return $m [ 1 ];
}
} catch ( \Exception $e ) {
$this -> logger -> logInfo ( "Could not fetch module ID for { $repo } : " . $e -> getMessage ());
}
return null ;
}
/**
* Generate PR body text
*/
private function generatePRBody ( array $summary ) : string
{
$body = "## MokoStandards Synchronization \n\n " ;
$body .= "This PR synchronizes workflows, configurations, and scripts from the MokoStandards repository. \n\n " ;
// Summary statistics
$body .= "### Summary \n " ;
$body .= "- 🆕 **Created**: " . count ( array_filter ( $summary [ 'copied' ], fn ( $i ) => $i [ 'action' ] === 'created' )) . " files \n " ;
$body .= "- 🔄 **Updated**: " . count ( array_filter ( $summary [ 'copied' ], fn ( $i ) => $i [ 'action' ] === 'updated' )) . " files \n " ;
$body .= "- ⚠️ **Skipped**: " . count ( $summary [ 'skipped' ]) . " files \n " ;
$body .= "- 📊 **Total**: " . $summary [ 'total' ] . " files processed \n\n " ;
// List copied files
if ( ! empty ( $summary [ 'copied' ])) {
$body .= "### Files Copied \n\n " ;
foreach ( $summary [ 'copied' ] as $item ) {
$action = $item [ 'action' ] === 'created' ? '🆕' : '🔄' ;
$body .= "- { $action } ` { $item [ 'file' ] } ` \n " ;
}
$body .= " \n " ;
}
// List skipped files
if ( ! empty ( $summary [ 'skipped' ])) {
$body .= "### Files Skipped \n\n " ;
foreach ( $summary [ 'skipped' ] as $item ) {
$body .= "- ⚠️ ` { $item [ 'file' ] } ` - { $item [ 'reason' ] } \n " ;
}
$body .= " \n " ;
}
$body .= "### Review Notes \n " ;
$body .= "- Please review all changes carefully \n " ;
$body .= "- Ensure no custom configurations are overwritten \n " ;
$body .= "- Test workflows and scripts after merging \n " ;
$body .= "- Verify issue templates render correctly \n\n " ;
$body .= "--- \n " ;
$body .= "*This PR was automatically generated by the MokoStandards bulk sync process.* \n " ;
return $body ;
}
/**
* Synchronize multiple repositories
*
* @param string $org Organization name
* @param array $options Sync options (repo, skipArchived, dryRun, force)
* @return array Sync results with statistics
*/
public function synchronize ( string $org , array $options = []) : array
{
$specificRepo = $options [ 'repo' ] ?? null ;
$skipArchived = $options [ 'skipArchived' ] ?? false ;
$dryRun = $options [ 'dryRun' ] ?? false ;
$force = $options [ 'force' ] ?? false ;
$txn = $this -> logger -> startTransaction ( 'bulk_synchronize' );
try {
// Get list of repositories
$repos = $this -> getRepositories ( $org , $skipArchived );
if ( $specificRepo ) {
$repos = array_filter ( $repos , fn ( $repo ) => $repo [ 'name' ] === $specificRepo );
}
$total = count ( $repos );
$results = [
'total' => $total ,
'success' => 0 ,
'skipped' => 0 ,
'failed' => 0 ,
'repositories' => [],
];
foreach ( $repos as $index => $repo ) {
$repoName = $repo [ 'name' ];
$progress = $index + 1 ;
try {
$updated = $this -> processRepository ( $org , $repoName , $dryRun , $force );
if ( $updated ) {
$results [ 'success' ] ++ ;
$this -> metrics -> increment ( 'repos_updated_total' , [ 'status' => 'success' ]);
$results [ 'repositories' ][ $repoName ] = 'updated' ;
} else {
$results [ 'skipped' ] ++ ;
$this -> metrics -> increment ( 'repos_updated_total' , [ 'status' => 'skipped' ]);
$results [ 'repositories' ][ $repoName ] = 'skipped' ;
}
} catch ( Exception $e ) {
$results [ 'failed' ] ++ ;
$this -> metrics -> increment ( 'repos_updated_total' , [ 'status' => 'failed' ]);
$results [ 'repositories' ][ $repoName ] = 'failed: ' . $e -> getMessage ();
}
// Save checkpoint
$this -> checkpoints -> saveCheckpoint ( 'bulk_sync' , [
'processed' => $progress ,
'total' => $total ,
'results' => $results ,
]);
}
$txn -> end ( 'success' );
return $results ;
} catch ( Exception $e ) {
$txn -> end ( 'failure' );
throw $e ;
}
}
/**
* Apply labels to a PR or issue, creating any that don't yet exist on the repo.
*
* @param string $org GitHub organisation
* @param string $repo Repository name
* @param int $number PR or issue number
* @param list<string> $labels Label names to apply
*/
public function applyLabels ( string $org , string $repo , int $number , array $labels ) : void
{
// Ensure labels exist on the repo before applying
$existingLabels = $this -> adapter -> listLabels ( $org , $repo );
$existingNames = array_column ( $existingLabels , 'name' );
foreach ( $labels as $label ) {
if ( ! in_array ( $label , $existingNames , true )) {
try {
$this -> adapter -> createLabel ( $org , $repo , $label ,
match ( $label ) {
'mokostandards' => 'B60205' ,
'type: chore' => 'FEF2C0' ,
'automation' => '8B4513' ,
default => 'EDEDED' ,
},
match ( $label ) {
'mokostandards' => 'MokoStandards compliance' ,
'type: chore' => 'Maintenance tasks' ,
'automation' => 'Automated processes or scripts' ,
default => '' ,
}
);
} catch ( \Exception $createEx ) { /* already exists race — ignore */ }
}
}
try {
$this -> adapter -> addIssueLabels ( $org , $repo , $number , $labels );
} catch ( \Exception $e ) {
$this -> logger -> logInfo ( "Could not apply labels to # { $number } : " . $e -> getMessage ());
}
}
}