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-04-16 15:51:51 -05:00
* REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards-API
2026-04-15 02:35:30 +00:00
* PATH: /lib/Enterprise/RepositorySynchronizer.php
2026-04-13 06:12:04 +00:00
* VERSION: 04.06.00
* BRIEF: Repository synchronization enterprise library
*/
declare ( strict_types = 1 );
namespace MokoEnterprise ;
use Exception ;
use RuntimeException ;
/**
* Repository Synchronizer
*
* Enterprise library for synchronizing files across multiple repositories
* based on configuration and override files.
*/
class RepositorySynchronizer
{
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 ;
private ApiClient $apiClient ;
private GitPlatformAdapter $adapter ;
private AuditLogger $logger ;
private MetricsCollector $metrics ;
private CheckpointManager $checkpoints ;
private DefinitionParser $definitionParser ;
/**
* Constructor
*
* @param ApiClient $apiClient Raw API client (kept for backward compatibility)
* @param AuditLogger $logger Audit logger
* @param MetricsCollector $metrics Metrics collector
* @param CheckpointManager|null $checkpoints Checkpoint manager
* @param DefinitionParser|null $definitionParser Definition parser
* @param GitPlatformAdapter|null $adapter Platform adapter (auto-created from ApiClient if null)
*/
public function __construct (
ApiClient $apiClient ,
AuditLogger $logger ,
MetricsCollector $metrics ,
? CheckpointManager $checkpoints = null ,
? DefinitionParser $definitionParser = null ,
? GitPlatformAdapter $adapter = null
) {
$this -> apiClient = $apiClient ;
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 ();
}
/**
* 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 ;
}
}
$this -> logger -> logInfo ( "Loaded " . count ( $filesToSync ) . " sync entries from definition for { $platform } " );
if ( empty ( $filesToSync )) {
$this -> logger -> logWarning ( "No syncable entries found in definition for platform ' { $platform } ', skipping { $repo } " );
return false ;
}
// Check if there's already a PR open for this repo.
// With --force, proceed anyway — createSyncPR() will reset the branch
// and update the existing PR body rather than creating a duplicate.
$existingPR = $this -> checkForExistingPR ( $org , $repo );
if ( $existingPR && ! $force ) {
$this -> logger -> logInfo ( "PR # { $existingPR } already exists for { $repo } , skipping (use --force to re-sync)" );
return false ;
}
if ( $existingPR && $force ) {
$this -> logger -> logInfo ( "PR # { $existingPR } already exists for { $repo } — force flag set, re-syncing" );
}
// Create PR with file updates driven by the definition
2026-04-16 19:18:44 -05:00
$result = $this -> createSyncPR ( $org , $repo , $platform , $filesToSync , $standardsRoot , $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
* @param string $platform Detected platform slug (e.g. 'crm-module')
* @param array $repoInfo Raw GitHub API repository object
* @param array $summary Sync result from createSyncPR: {copied[], skipped[], total}
* @return bool
*/
private function generateRepositoryDefinition (
string $org ,
string $repo ,
string $platform ,
array $repoInfo ,
array $summary
) : bool {
try {
$this -> logger -> logInfo ( "Writing sync tracking definition for { $org } / { $repo } " );
$timestamp = date ( 'c' );
$description = addslashes ( $repoInfo [ 'description' ] ?? '' );
$defaultBranch = $repoInfo [ 'default_branch' ] ?? 'main' ;
// Resolve repo root relative to this file's location
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-04-15 02:35:30 +00:00
$baseDefPath = " { $repoRoot } /definitions/default/default-repository.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' ];
private function detectPlatform ( array $repoInfo ) : string
{
$name = $repoInfo [ 'name' ] ?? '' ;
$nameLower = strtolower ( $name );
$description = strtolower ( $repoInfo [ 'description' ] ?? '' );
$topics = $repoInfo [ 'topics' ] ?? [];
// Explicit platform repos — full Dolibarr installation, not a module
if ( in_array ( $name , self :: CRM_PLATFORM_REPOS , true )) {
return 'crm-platform' ;
}
if ( in_array ( 'dolibarr-platform' , $topics )) {
return 'crm-platform' ;
}
2026-04-14 20:16:28 -05:00
// Check topics first — templates before generic joomla
if ( in_array ( 'joomla-template' , $topics )) {
return 'joomla-template' ;
}
2026-04-13 06:12:04 +00:00
if ( in_array ( 'joomla' , $topics ) || in_array ( 'joomla-extension' , $topics )) {
return 'waas-component' ;
}
if ( in_array ( 'dolibarr' , $topics ) || in_array ( 'dolibarr-module' , $topics )) {
return 'crm-module' ;
}
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' ))) {
return 'joomla-template' ;
}
2026-04-13 06:12:04 +00:00
if ( str_contains ( $nameLower , 'joomla' ) || str_contains ( $nameLower , 'waas' )) {
return 'waas-component' ;
}
if ( str_contains ( $nameLower , 'doli' ) || str_contains ( $nameLower , 'crm' )) {
return 'crm-module' ;
}
// Check description patterns
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' )) {
return 'joomla-template' ;
}
2026-04-13 06:12:04 +00:00
if ( str_contains ( $description , 'joomla' ) || str_contains ( $description , 'component' )) {
return 'waas-component' ;
}
if ( str_contains ( $description , 'dolibarr' ) || str_contains ( $description , 'module' )) {
return 'crm-module' ;
}
// Default
return 'default-repository' ;
}
/**
* Create a PR with sync updates driven by the flat entry list from DefinitionParser.
*
* @param string $org
* @param string $repo
* @param string $platform Detected platform slug (e.g. 'crm-module')
* @param array<int, array{source?: string, inline_content?: string, destination: string, always_overwrite: bool}> $filesToSync
* @param string $repoRoot Absolute path to the MokoStandards repository root
* @param bool $force When true, overwrite files even when always_overwrite = false
* @return array{number: ?int, summary: array}
*/
private function createSyncPR ( string $org , string $repo , string $platform , array $filesToSync , string $repoRoot , bool $force ) : array
{
$nullResult = [ 'number' => null , 'summary' => []];
try {
$repoInfo = $this -> adapter -> getRepo ( $org , $repo );
$defaultBranch = $repoInfo [ 'default_branch' ] ?? 'main' ;
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-04-16 19:11:47 -05:00
// Migrate .mokostandards (default branch only)
$this -> migrateMokoStandards ( $org , $repo , $defaultBranch , $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
*
* Dolibarr-specific tokens (crm-module platform only):
* {{MODULE_NAME}} — lowercase module name (e.g. mokocrm)
* {{MODULE_CLASS}} — PascalCase class name (e.g. MokoCRM)
* {{MODULE_ID}} — $this->numero from descriptor (null → left unreplaced)
*
* @param string $content Raw template content
* @param string $repo Repository name
* @param string $org Organisation name
* @param string $platform Detected platform slug
* @param array $repoInfo Raw GitHub API repository object
* Merge a git config file (gitignore / gitattributes / ftp_ignore) by
* ensuring all template lines are present without removing custom entries.
*
* Strategy: take the existing remote content, then append any template
* lines that are missing. Comments and blank lines from the template are
* included to preserve section structure. Duplicate non-blank lines are
* never added.
*
* @param string $existing Current file content from the remote repo
* @param string $template Template file content from MokoStandards
* @return string Merged content
*/
/**
* Return shared workflow entries that should be synced to all platforms.
*
* The .tf definition parser cannot extract workflows from nested
* subdirectories blocks because heredoc content in CLAUDE.md disrupts
* bracket matching. This method provides the workflow list directly.
*
* @return array<int, array{source: string, destination: string, always_overwrite: bool}>
*/
/**
* Ensure the remote composer.json requires mokoconsulting-tech/enterprise.
* If the package is missing, add it and commit the change to the sync branch.
*/
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 ;
$canOverwrite = ! $isProtected && ( $force || $entry [ 'always_overwrite' ]) && ! ( $entry [ 'protected' ] ?? false );
if ( $isReadme ) {
$summary [ 'skipped' ][] = [ 'file' => $targetPath , 'reason' => 'README — never overwritten' ];
continue ;
}
if ( $isChangelog ) {
$summary [ 'skipped' ][] = [ 'file' => $targetPath , 'reason' => 'CHANGELOG — never overwritten' ];
continue ;
}
if ( isset ( $entry [ 'inline_content' ])) {
$content = $entry [ 'inline_content' ];
} else {
$sourcePath = rtrim ( $repoRoot , '/' ) . '/' . ltrim ( $entry [ 'source' ] ?? '' , '/' );
if ( ! file_exists ( $sourcePath )) {
$summary [ 'skipped' ][] = [ 'file' => $targetPath , 'reason' => 'Source file not found' ];
continue ;
}
$content = file_get_contents ( $sourcePath );
if ( $content === false ) {
$summary [ 'skipped' ][] = [ 'file' => $targetPath , 'reason' => 'Failed to read source' ];
continue ;
}
}
$content = $this -> processTemplateContent ( $content , $repo , $org , $platform , $repoInfo , $moduleId );
try {
$existingFile = $this -> adapter -> getFileContents ( $org , $repo , $targetPath , $branchName );
if ( ! $canOverwrite ) {
$existingDecoded = base64_decode ( $existingFile [ 'content' ] ?? '' );
$hasStaleTokens = ( bool ) preg_match ( '/\{\{[A-Z_a-z]+\}\}|\{[A-Z_]{4,}\}/' , $existingDecoded );
if ( ! $hasStaleTokens ) {
$summary [ 'skipped' ][] = [ 'file' => $targetPath , 'reason' => 'Preserved (always_overwrite=false)' ];
continue ;
}
}
$isGitConfig = in_array ( basename ( $targetPath ), [ '.gitignore' , '.gitattributes' , '.ftpignore' ], true );
if ( $isGitConfig ) {
$existingDecoded = base64_decode ( $existingFile [ 'content' ] ?? '' );
$content = $this -> mergeGitConfigFile ( $existingDecoded , $content );
}
$this -> adapter -> createOrUpdateFile (
$org , $repo , $targetPath , $content ,
"chore: update { $targetPath } from MokoStandards" ,
$existingFile [ 'sha' ] ?? null ,
$branchName
);
$this -> logger -> logInfo ( "Updated: { $targetPath } ( { $branchName } )" );
$summary [ 'copied' ][] = [ 'file' => $targetPath , 'action' => 'updated' ];
} catch ( Exception $e ) {
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
try {
$this -> adapter -> createOrUpdateFile (
$org , $repo , $targetPath , $content ,
"chore: add { $targetPath } from MokoStandards" ,
null ,
$branchName
);
$this -> logger -> logInfo ( "Created: { $targetPath } ( { $branchName } )" );
$summary [ 'copied' ][] = [ 'file' => $targetPath , 'action' => 'created' ];
} catch ( Exception $e2 ) {
if ( str_contains ( $e2 -> getMessage (), "sha" ) || str_contains ( $e2 -> getMessage (), '422' )) {
try {
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
$existing = $this -> adapter -> getFileContents ( $org , $repo , $targetPath , $branchName );
$this -> adapter -> createOrUpdateFile (
$org , $repo , $targetPath , $content ,
"chore: update { $targetPath } from MokoStandards" ,
$existing [ 'sha' ] ?? null ,
$branchName
);
$summary [ 'copied' ][] = [ 'file' => $targetPath , 'action' => 'updated' ];
} catch ( Exception $e3 ) {
$summary [ 'skipped' ][] = [ 'file' => $targetPath , 'reason' => 'API error: ' . $e3 -> getMessage ()];
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
}
} else {
$summary [ 'skipped' ][] = [ 'file' => $targetPath , 'reason' => 'API error: ' . $e2 -> getMessage ()];
}
}
}
}
return $summary ;
}
2026-04-13 06:12:04 +00:00
/**
* Migrate .mokostandards from repo root to .github/.mokostandards.
* Deletes the root file after copying to .github/.
*/
private function migrateMokoStandards ( string $org , string $repo , string $branchName , array & $summary ) : void
{
2026-04-14 20:16:28 -05:00
$metaDir = $this -> adapter -> getMetadataDir ();
$targetPath = " { $metaDir } /.mokostandards" ;
2026-04-13 06:12:04 +00:00
// Check if .mokostandards exists in root
try {
$rootFile = $this -> adapter -> getFileContents ( $org , $repo , '.mokostandards' , $branchName );
} catch ( Exception $e ) {
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
return ; // Doesn't exist in root — nothing to migrate
}
2026-04-14 20:16:28 -05:00
// Check if already exists in metadata dir
$existsInMetaDir = false ;
2026-04-13 06:12:04 +00:00
try {
2026-04-14 20:16:28 -05:00
$this -> adapter -> getFileContents ( $org , $repo , $targetPath , $branchName );
$existsInMetaDir = true ;
2026-04-13 06:12:04 +00:00
} catch ( Exception $e ) {
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
}
$content = base64_decode ( $rootFile [ 'content' ] ?? '' );
$rootSha = $rootFile [ 'sha' ] ?? '' ;
2026-04-14 20:16:28 -05:00
if ( ! $existsInMetaDir ) {
// Copy to metadata dir
2026-04-13 06:12:04 +00:00
try {
$this -> adapter -> createOrUpdateFile (
2026-04-14 20:16:28 -05:00
$org , $repo , $targetPath , $content ,
"chore: migrate .mokostandards to { $metaDir } /" ,
2026-04-13 06:12:04 +00:00
null , $branchName
);
2026-04-14 20:16:28 -05:00
$this -> logger -> logInfo ( "Migrated .mokostandards → { $targetPath } " );
$summary [ 'copied' ][] = [ 'file' => $targetPath , 'action' => 'migrated from root' ];
2026-04-13 06:12:04 +00:00
} catch ( Exception $e ) {
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
return ;
}
}
// Delete from root
if ( ! empty ( $rootSha )) {
try {
$this -> adapter -> deleteFile (
$org , $repo , '.mokostandards' , $rootSha ,
2026-04-14 20:16:28 -05:00
"chore: remove .mokostandards from root (moved to { $metaDir } /)" ,
2026-04-13 06:12:04 +00:00
$branchName
);
$this -> logger -> logInfo ( "Deleted root .mokostandards" );
} catch ( Exception $e ) {
$this -> adapter -> getApiClient () -> resetCircuitBreaker ();
}
}
}
private function ensureComposerEnterprise ( string $org , string $repo , string $branchName , array & $summary ) : void
{
try {
$file = $this -> adapter -> getFileContents ( $org , $repo , 'composer.json' , $branchName );
} catch ( Exception $e ) {
return ; // No composer.json — skip
}
$content = base64_decode ( $file [ 'content' ] ?? '' );
$json = json_decode ( $content , true );
if ( ! is_array ( $json )) {
return ;
}
$expectedConstraint = 'dev-' . self :: VERSION_BRANCH ;
// Check if enterprise package is already required with correct constraint
$currentConstraint = $json [ 'require' ][ 'mokoconsulting-tech/enterprise' ]
?? $json [ 'require-dev' ][ 'mokoconsulting-tech/enterprise' ]
?? null ;
if ( $currentConstraint === $expectedConstraint ) {
return ; // Already correct
}
// Add or update the enterprise package to point to version branch
$json [ 'require' ] = $json [ 'require' ] ?? [];
$json [ 'require' ][ 'mokoconsulting-tech/enterprise' ] = $expectedConstraint ;
// Sort require keys for consistency
ksort ( $json [ 'require' ]);
$newContent = json_encode ( $json , JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . " \n " ;
try {
$this -> adapter -> createOrUpdateFile (
$org , $repo , 'composer.json' , $newContent ,
'chore: add mokoconsulting-tech/enterprise dependency' ,
$file [ 'sha' ] ?? null ,
$branchName
);
$this -> logger -> logInfo ( "Added mokoconsulting-tech/enterprise to composer.json" );
$summary [ 'copied' ][] = [ 'file' => 'composer.json' , 'action' => 'enterprise dependency added' ];
} catch ( Exception $e ) {
$this -> logger -> logWarning ( "Could not update composer.json: " . $e -> getMessage ());
}
}
private function getSharedWorkflows ( string $platform , string $repoRoot ) : array
{
$root = rtrim ( $repoRoot , '/' );
$wfDir = $this -> adapter -> getWorkflowDir ();
$shared = [
[ 'templates/workflows/shared/enterprise-firewall-setup.yml.template' , " { $wfDir } /enterprise-firewall-setup.yml" ],
[ 'templates/workflows/shared/sync-version-on-merge.yml.template' , " { $wfDir } /sync-version-on-merge.yml" ],
[ 'templates/workflows/shared/repository-cleanup.yml.template' , " { $wfDir } /repository-cleanup.yml" ],
[ 'templates/workflows/shared/auto-dev-issue.yml.template' , " { $wfDir } /auto-dev-issue.yml" ],
[ 'templates/workflows/shared/branch-freeze.yml.template' , " { $wfDir } /branch-freeze.yml" ],
[ 'templates/workflows/shared/auto-assign.yml.template' , " { $wfDir } /auto-assign.yml" ],
[ 'templates/workflows/shared/changelog-validation.yml.template' , " { $wfDir } /changelog-validation.yml" ],
[ '.github/workflows/standards-compliance.yml' , " { $wfDir } /standards-compliance.yml" ],
];
// CodeQL is GitHub-only; on Gitea, Trivy replaces it
if ( $this -> adapter -> getPlatformName () === 'github' ) {
$shared [] = [ '.github/workflows/codeql-analysis.yml' , " { $wfDir } /codeql-analysis.yml" ];
}
// Platform-specific workflows
if ( $platform === 'crm-module' ) {
$shared [] = [ 'templates/workflows/shared/deploy-dev.yml.template' , " { $wfDir } /deploy-dev.yml" ];
$shared [] = [ 'templates/workflows/shared/deploy-demo.yml.template' , " { $wfDir } /deploy-demo.yml" ];
$shared [] = [ 'templates/workflows/dolibarr/auto-release.yml.template' , " { $wfDir } /auto-release.yml" ];
$shared [] = [ 'templates/workflows/dolibarr/ci-dolibarr.yml.template' , " { $wfDir } /ci-dolibarr.yml" ];
$shared [] = [ 'templates/workflows/dolibarr/publish-to-mokodolimods.yml.template' , " { $wfDir } /publish-to-mokodolimods.yml" ];
$shared [] = [ 'templates/workflows/dolibarr/repo_health.yml.template' , " { $wfDir } /repo_health.yml" ];
} elseif ( $platform === 'crm-platform' ) {
$shared [] = [ 'templates/workflows/shared/deploy-dev.yml.template' , " { $wfDir } /deploy-dev.yml" ];
$shared [] = [ 'templates/workflows/shared/deploy-demo.yml.template' , " { $wfDir } /deploy-demo.yml" ];
$shared [] = [ 'templates/workflows/dolibarr/auto-release.yml.template' , " { $wfDir } /auto-release.yml" ];
$shared [] = [ 'templates/workflows/dolibarr/ci-dolibarr.yml.template' , " { $wfDir } /ci-dolibarr.yml" ];
2026-04-14 20:16:28 -05:00
} elseif ( $platform === 'waas-component' || $platform === 'joomla-template' ) {
2026-04-13 06:12:04 +00:00
$shared [] = [ 'templates/workflows/joomla/auto-release.yml.template' , " { $wfDir } /auto-release.yml" ];
$shared [] = [ 'templates/workflows/joomla/update-server.yml.template' , " { $wfDir } /update-server.yml" ];
$shared [] = [ 'templates/workflows/joomla/ci-joomla.yml.template' , " { $wfDir } /ci-joomla.yml" ];
$shared [] = [ 'templates/workflows/joomla/repo_health.yml.template' , " { $wfDir } /repo_health.yml" ];
$shared [] = [ 'templates/workflows/joomla/deploy-manual.yml.template' , " { $wfDir } /deploy-manual.yml" ];
} else {
$shared [] = [ 'templates/workflows/shared/deploy-dev.yml.template' , " { $wfDir } /deploy-dev.yml" ];
$shared [] = [ 'templates/workflows/shared/deploy-demo.yml.template' , " { $wfDir } /deploy-demo.yml" ];
$shared [] = [ 'templates/workflows/shared/auto-release.yml.template' , " { $wfDir } /auto-release.yml" ];
}
// CODEOWNERS — GitHub only; Gitea doesn't enforce it
if ( $this -> adapter -> getPlatformName () === 'github' ) {
$shared [] = [ 'templates/github/CODEOWNERS' , '.github/CODEOWNERS' ];
}
// Platform-specific gitignore (merged, not replaced)
$gitignoreMap = [
2026-04-14 20:16:28 -05:00
'crm-module' => 'templates/configs/gitignore.dolibarr' ,
'crm-platform' => 'templates/configs/gitignore.dolibarr' ,
'waas-component' => 'templates/configs/.gitignore.joomla' ,
'joomla-template' => '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.
$entries = [
[
'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)
if ( $platform === 'crm-module' ) {
$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 ) {
'crm-module' => 'Dolibarr module' ,
'waas-component' => 'Joomla extension' ,
'default-repository' => 'PHP library' ,
default => ucfirst ( str_replace ( '-' , ' ' , $platform )),
};
// Derive Dolibarr module identifiers from the repository name
$moduleName = strtolower ( preg_replace ( '/[^a-zA-Z0-9]/' , '' , $repo ));
$moduleClass = $repo ; // Repo name is the PascalCase class (e.g. MokoCRM)
// Build replacement map — uppercase tokens take precedence; legacy lowercase kept for compat
$map = [
// Uppercase tokens (used in CLAUDE.md / copilot-instructions templates)
'{{REPO_NAME}}' => $repoInfo [ 'name' ] ?? $repo ,
'{{REPO_URL}}' => "https://github.com/ { $org } / { $repo } " ,
'{{REPO_DESCRIPTION}}' => $repoInfo [ 'description' ] ?? '' ,
'{{PRIMARY_LANGUAGE}}' => $repoInfo [ 'language' ] ?? '' ,
'{{PLATFORM_TYPE}}' => $platformType ,
'{{MODULE_NAME}}' => $moduleName ,
'{{MODULE_CLASS}}' => $moduleClass ,
'{{WORKFLOW_DIR}}' => $this -> adapter -> getWorkflowDir (),
// Legacy lowercase tokens
'{{repo_name}}' => $repoInfo [ 'name' ] ?? $repo ,
'{{repo_name_lower}}' => strtolower ( $repo ),
'{{org}}' => $org ,
'{{platform}}' => $platform ,
'{{standards_version}}' => self :: STANDARDS_VERSION ,
'{{standards_minor}}' => self :: STANDARDS_MINOR ,
'{{standards_branch}}' => self :: VERSION_BRANCH ,
// Single-brace tokens — used by GitHub repository templates and older MokoStandards stubs
'{REPO_NAME}' => $repoInfo [ 'name' ] ?? $repo ,
'{REPO_URL}' => "https://github.com/ { $org } / { $repo } " ,
'{REPO_DESCRIPTION}' => $repoInfo [ 'description' ] ?? '' ,
'{PRIMARY_LANGUAGE}' => $repoInfo [ 'language' ] ?? '' ,
'{PLATFORM_TYPE}' => $platformType ,
'{MODULE_NAME}' => $moduleName ,
'{MODULE_CLASS}' => $moduleClass ,
'{MODULE_ID}' => '' , // overridden below when moduleId is available
'{repo_name}' => $repoInfo [ 'name' ] ?? $repo ,
'{repo_name_lower}' => strtolower ( $repo ),
'{org}' => $org ,
];
// Only replace {{MODULE_ID}} / {MODULE_ID} if we actually have the value; otherwise leave
// the placeholder intact so the CLAUDE.md self-repair block can fill it in later.
if ( $moduleId !== null ) {
$map [ '{{MODULE_ID}}' ] = $moduleId ;
$map [ '{MODULE_ID}' ] = $moduleId ;
} else {
// Remove the empty single-brace placeholder so it doesn't corrupt values
unset ( $map [ '{MODULE_ID}' ]);
}
return strtr ( $content , $map );
}
/**
* Fetch the Dolibarr module numero ($this->numero) from the module descriptor.
*
* Searches the repository tree for src/core/modules/mod*.class.php and extracts
* the unique module number. Returns null if not found or on any API error.
*
* @param string $org GitHub organisation
* @param string $repo Repository name
* @return string|null Module ID string, or null if unavailable
*/
private function fetchModuleId ( string $org , string $repo ) : ? string
{
try {
$treeEntries = $this -> adapter -> getTree ( $org , $repo , 'HEAD' , true );
$paths = array_column ( $treeEntries , 'path' );
$descriptors = array_values ( array_filter (
$paths ,
static fn ( string $p ) : bool => ( bool ) preg_match ( '#src/core/modules/mod\w+\.class\.php$#' , $p )
));
if ( empty ( $descriptors )) {
return null ;
}
$fileData = $this -> adapter -> getFileContents ( $org , $repo , $descriptors [ 0 ]);
$content = base64_decode ( str_replace ([ " \n " , " \r " ], '' , $fileData [ 'content' ] ?? '' ));
if ( preg_match ( '/\$this->numero\s*=\s*(\d+)/' , $content , $m )) {
return $m [ 1 ];
}
} catch ( \Exception $e ) {
$this -> logger -> logInfo ( "Could not fetch module ID for { $repo } : " . $e -> getMessage ());
}
return null ;
}
/**
* Generate PR body text
*/
private function generatePRBody ( array $summary ) : string
{
$body = "## MokoStandards Synchronization \n\n " ;
$body .= "This PR synchronizes workflows, configurations, and scripts from the MokoStandards repository. \n\n " ;
// Summary statistics
$body .= "### Summary \n " ;
$body .= "- 🆕 **Created**: " . count ( array_filter ( $summary [ 'copied' ], fn ( $i ) => $i [ 'action' ] === 'created' )) . " files \n " ;
$body .= "- 🔄 **Updated**: " . count ( array_filter ( $summary [ 'copied' ], fn ( $i ) => $i [ 'action' ] === 'updated' )) . " files \n " ;
$body .= "- ⚠️ **Skipped**: " . count ( $summary [ 'skipped' ]) . " files \n " ;
$body .= "- 📊 **Total**: " . $summary [ 'total' ] . " files processed \n\n " ;
// List copied files
if ( ! empty ( $summary [ 'copied' ])) {
$body .= "### Files Copied \n\n " ;
foreach ( $summary [ 'copied' ] as $item ) {
$action = $item [ 'action' ] === 'created' ? '🆕' : '🔄' ;
$body .= "- { $action } ` { $item [ 'file' ] } ` \n " ;
}
$body .= " \n " ;
}
// List skipped files
if ( ! empty ( $summary [ 'skipped' ])) {
$body .= "### Files Skipped \n\n " ;
foreach ( $summary [ 'skipped' ] as $item ) {
$body .= "- ⚠️ ` { $item [ 'file' ] } ` - { $item [ 'reason' ] } \n " ;
}
$body .= " \n " ;
}
$body .= "### Review Notes \n " ;
$body .= "- Please review all changes carefully \n " ;
$body .= "- Ensure no custom configurations are overwritten \n " ;
$body .= "- Test workflows and scripts after merging \n " ;
$body .= "- Verify issue templates render correctly \n\n " ;
$body .= "--- \n " ;
$body .= "*This PR was automatically generated by the MokoStandards bulk sync process.* \n " ;
return $body ;
}
/**
* Synchronize multiple repositories
*
* @param string $org Organization name
* @param array $options Sync options (repo, skipArchived, dryRun, force)
* @return array Sync results with statistics
*/
public function synchronize ( string $org , array $options = []) : array
{
$specificRepo = $options [ 'repo' ] ?? null ;
$skipArchived = $options [ 'skipArchived' ] ?? false ;
$dryRun = $options [ 'dryRun' ] ?? false ;
$force = $options [ 'force' ] ?? false ;
$txn = $this -> logger -> startTransaction ( 'bulk_synchronize' );
try {
// Get list of repositories
$repos = $this -> getRepositories ( $org , $skipArchived );
if ( $specificRepo ) {
$repos = array_filter ( $repos , fn ( $repo ) => $repo [ 'name' ] === $specificRepo );
}
$total = count ( $repos );
$results = [
'total' => $total ,
'success' => 0 ,
'skipped' => 0 ,
'failed' => 0 ,
'repositories' => [],
];
foreach ( $repos as $index => $repo ) {
$repoName = $repo [ 'name' ];
$progress = $index + 1 ;
try {
$updated = $this -> processRepository ( $org , $repoName , $dryRun , $force );
if ( $updated ) {
$results [ 'success' ] ++ ;
$this -> metrics -> increment ( 'repos_updated_total' , [ 'status' => 'success' ]);
$results [ 'repositories' ][ $repoName ] = 'updated' ;
} else {
$results [ 'skipped' ] ++ ;
$this -> metrics -> increment ( 'repos_updated_total' , [ 'status' => 'skipped' ]);
$results [ 'repositories' ][ $repoName ] = 'skipped' ;
}
} catch ( Exception $e ) {
$results [ 'failed' ] ++ ;
$this -> metrics -> increment ( 'repos_updated_total' , [ 'status' => 'failed' ]);
$results [ 'repositories' ][ $repoName ] = 'failed: ' . $e -> getMessage ();
}
// Save checkpoint
$this -> checkpoints -> saveCheckpoint ( 'bulk_sync' , [
'processed' => $progress ,
'total' => $total ,
'results' => $results ,
]);
}
$txn -> end ( 'success' );
return $results ;
} catch ( Exception $e ) {
$txn -> end ( 'failure' );
throw $e ;
}
}
/**
* Apply labels to a PR or issue, creating any that don't yet exist on the repo.
*
* @param string $org GitHub organisation
* @param string $repo Repository name
* @param int $number PR or issue number
* @param list<string> $labels Label names to apply
*/
public function applyLabels ( string $org , string $repo , int $number , array $labels ) : void
{
// Ensure labels exist on the repo before applying
$existingLabels = $this -> adapter -> listLabels ( $org , $repo );
$existingNames = array_column ( $existingLabels , 'name' );
foreach ( $labels as $label ) {
if ( ! in_array ( $label , $existingNames , true )) {
try {
$this -> adapter -> createLabel ( $org , $repo , $label ,
match ( $label ) {
'mokostandards' => 'B60205' ,
'type: chore' => 'FEF2C0' ,
'automation' => '8B4513' ,
default => 'EDEDED' ,
},
match ( $label ) {
'mokostandards' => 'MokoStandards compliance' ,
'type: chore' => 'Maintenance tasks' ,
'automation' => 'Automated processes or scripts' ,
default => '' ,
}
);
} catch ( \Exception $createEx ) { /* already exists race — ignore */ }
}
}
try {
$this -> adapter -> addIssueLabels ( $org , $repo , $number , $labels );
} catch ( \Exception $e ) {
$this -> logger -> logInfo ( "Could not apply labels to # { $number } : " . $e -> getMessage ());
}
}
}