diff --git a/automation/bulk_joomla_template.php b/automation/bulk_joomla_template.php deleted file mode 100644 index 496eabd..0000000 --- a/automation/bulk_joomla_template.php +++ /dev/null @@ -1,937 +0,0 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoPlatform.Automation - * INGROUP: MokoPlatform.Scripts - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli - * PATH: /automation/bulk_joomla_template.php - * BRIEF: Bulk scaffold and sync Joomla template repositories - * - * USAGE - * php automation/bulk_joomla_template.php --scaffold --name=MokoTheme - * php automation/bulk_joomla_template.php --scaffold --name=MokoTheme --client=administrator - * php automation/bulk_joomla_template.php --sync --repos=MokoTheme,MokoDarkTheme - * php automation/bulk_joomla_template.php --sync --all - * php automation/bulk_joomla_template.php --list - */ - -declare(strict_types=1); - -require_once __DIR__ . '/../vendor/autoload.php'; -require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; - -use MokoCli\{ - AuditLogger, - CliFramework, - Config, - GitPlatformAdapter, - MetricsCollector, - PlatformAdapterFactory -}; - -/** - * Bulk Joomla Template Manager - * - * Provides three operations for Joomla template projects: - * --scaffold: Create a new template repository with the full directory structure - * --sync: Push mokocli files to existing template repositories - * --list: List all repositories tagged as joomla-template - * - * Works with both GitHub and Gitea via the PlatformAdapterFactory. - */ -class BulkJoomlaTemplate extends CliFramework -{ - public const DEFAULT_ORG = 'MokoConsulting'; - public const VERSION = '09.23.00'; - - private GitPlatformAdapter $adapter; - private Config $config; - - protected function configure(): void - { - $this->setDescription('Bulk Joomla template management'); - $this->addArgument('--org', 'Organization', self::DEFAULT_ORG); - $this->addArgument('--scaffold', 'Create new template repo', false); - $this->addArgument('--sync', 'Sync files to template repos', false); - $this->addArgument('--list', 'List template repos', false); - $this->addArgument('--name', 'Template name for scaffold', ''); - $this->addArgument('--client', 'Joomla client: site or admin', 'site'); - $this->addArgument('--repos', 'Target repos (comma-separated)', ''); - $this->addArgument('--all', 'Sync all tagged repos', false); - $this->addArgument('--sync-updates', 'Sync updates.xml', false); - $this->addArgument('--private', 'Create as private repo', false); - $this->addArgument('--yes', 'Auto-confirm', false); - } - - protected function run(): int - { - $this->log("๐ŸŽจ Joomla Template Manager v" . self::VERSION, 'INFO'); - - $this->config = Config::load(); - - try { - $this->adapter = PlatformAdapterFactory::create($this->config); - } catch (\Exception $e) { - $this->log("โŒ Failed to initialize: " . $e->getMessage(), 'ERROR'); - return 1; - } - - $org = $this->getArgument('--org', self::DEFAULT_ORG); - $platform = $this->adapter->getPlatformName(); - $this->log("Platform: {$platform} | Organization: {$org}", 'INFO'); - - if ($this->getArgument('--list', false)) { - return $this->listTemplateRepos($org); - } - - if ($this->getArgument('--scaffold', false)) { - return $this->scaffoldTemplate($org); - } - - if ($this->getArgument('--sync', false)) { - return $this->syncTemplates($org); - } - - if ($this->getArgument('--sync-updates', false)) { - return $this->syncUpdatesBetweenPlatforms($org); - } - - $this->log("โŒ Specify --scaffold, --sync, --sync-updates, or --list", 'ERROR'); - return 1; - } - - // โ”€โ”€ List โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function listTemplateRepos(string $org): int - { - $repos = $this->findTemplateRepos($org); - - if (empty($repos)) { - $this->log("No joomla-template repositories found in {$org}", 'INFO'); - return 0; - } - - $this->log("\nJoomla template repositories in {$org}:", 'INFO'); - foreach ($repos as $repo) { - $vis = ($repo['private'] ?? false) ? 'private' : 'public'; - $url = $this->adapter->getRepoWebUrl($org, $repo['name']); - $this->log(" - {$repo['name']} ({$vis}) {$url}", 'INFO'); - } - $this->log("\nTotal: " . count($repos), 'INFO'); - - return 0; - } - - // โ”€โ”€ Scaffold โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function scaffoldTemplate(string $org): int - { - $name = $this->getArgument('--name', ''); - $client = $this->getArgument('--client', 'site'); - $dryRun = $this->dryRun; - - if (empty($name)) { - $this->log("โŒ --name is required for --scaffold", 'ERROR'); - $this->log(" Example: --name=MokoTheme", 'ERROR'); - return 1; - } - - if (!in_array($client, ['site', 'administrator'], true)) { - $this->log("โŒ --client must be 'site' or 'administrator'", 'ERROR'); - return 1; - } - - $shortName = $this->deriveShortName($name); - $this->log("\nScaffolding Joomla template:", 'INFO'); - $this->log(" Name: {$name}", 'INFO'); - $this->log(" Short name: {$shortName}", 'INFO'); - $this->log(" Client: {$client}", 'INFO'); - $this->log(" Element: tpl_{$shortName}", 'INFO'); - - if ($dryRun) { - $this->log("\n[DRY RUN] Would create repository and scaffold files", 'INFO'); - $this->printScaffoldPlan($shortName); - return 0; - } - - // Check if repo already exists - try { - $this->adapter->getRepo($org, $name); - $this->log("โŒ Repository {$org}/{$name} already exists", 'ERROR'); - return 1; - } catch (\Exception $e) { - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - - // Confirm - if (!$this->getArgument('--yes', false)) { - echo "\nCreate repository {$org}/{$name}? [y/N]: "; - $handle = fopen('php://stdin', 'r'); - $line = fgets($handle); - if ($handle) { - fclose($handle); - } - if (!is_string($line) || strtolower(trim($line)) !== 'y') { - $this->log("Cancelled.", 'INFO'); - return 0; - } - } - - // Create repository - $this->log("\nCreating repository...", 'INFO'); - try { - $isPrivate = $this->getArgument('--private', false); - $this->adapter->createOrgRepo($org, $name, [ - 'description' => "Joomla {$client} template โ€” {$name}", - 'private' => $isPrivate, - 'auto_init' => true, - ]); - $this->log(" โœ“ Repository created: {$org}/{$name}", 'INFO'); - } catch (\Exception $e) { - $this->log("โŒ Failed to create repository: " . $e->getMessage(), 'ERROR'); - return 1; - } - - // Set topics - try { - $this->adapter->setRepoTopics($org, $name, [ - 'joomla', 'joomla-template', 'template', "joomla-{$client}", - ]); - $this->log(" โœ“ Topics set", 'INFO'); - } catch (\Exception $e) { - $this->log(" โš ๏ธ Could not set topics: " . $e->getMessage(), 'WARN'); - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - - // Scaffold files - $this->log("\nScaffolding template files...", 'INFO'); - $files = $this->getScaffoldFiles($name, $shortName, $client, $org); - - $created = 0; - foreach ($files as $path => $content) { - try { - $this->adapter->createOrUpdateFile( - $org, - $name, - $path, - $content, - "chore: scaffold {$path}" - ); - $this->log(" โœ“ {$path}", 'INFO'); - $created++; - } catch (\Exception $e) { - $this->log(" โœ— {$path}: " . $e->getMessage(), 'ERROR'); - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - } - - // Apply branch protection - try { - $this->adapter->setBranchProtection($org, $name, 'main', [ - 'required_reviews' => 1, - 'dismiss_stale' => true, - 'block_on_rejected' => true, - ]); - $this->log(" โœ“ Branch protection applied", 'INFO'); - } catch (\Exception $e) { - $this->log(" โš ๏ธ Branch protection: " . $e->getMessage(), 'WARN'); - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - - $url = $this->adapter->getRepoWebUrl($org, $name); - $this->log("\nโœ… Template scaffolded: {$url}", 'INFO'); - $this->log(" {$created} files created", 'INFO'); - - return 0; - } - - // โ”€โ”€ Sync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function syncTemplates(string $org): int - { - $repos = []; - - if ($this->getArgument('--all', false)) { - $repos = $this->findTemplateRepos($org); - } else { - $reposArg = $this->getArgument('--repos', ''); - if (empty($reposArg)) { - $this->log("โŒ --repos or --all required for --sync", 'ERROR'); - return 1; - } - $names = array_filter(array_map('trim', explode(',', $reposArg))); - foreach ($names as $name) { - $repos[] = ['name' => $name]; - } - } - - if (empty($repos)) { - $this->log("No template repositories to sync", 'INFO'); - return 0; - } - - $this->log("\nSyncing " . count($repos) . " template repo(s)...", 'INFO'); - - $dryRun = $this->dryRun; - $success = 0; - $failed = 0; - - foreach ($repos as $repo) { - $name = $repo['name']; - $this->log("\n[{$name}]", 'INFO'); - - try { - $repoData = $this->adapter->getRepo($org, $name); - $shortName = $this->deriveShortName($name); - $branch = $repoData['default_branch'] ?? 'main'; - - $syncFiles = $this->getSyncFiles($name, $shortName); - - $updated = 0; - foreach ($syncFiles as $path => $content) { - if ($dryRun) { - $this->log(" (dry-run) {$path}", 'INFO'); - $updated++; - continue; - } - - // Check if file exists - $existingSha = null; - try { - $existing = $this->adapter->getFileContents($org, $name, $path, $branch); - $existingSha = $existing['sha'] ?? null; - } catch (\Exception $e) { - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - - try { - $this->adapter->createOrUpdateFile( - $org, - $name, - $path, - $content, - "chore: update {$path} from mokocli", - $existingSha, - $branch - ); - $this->log(" โœ“ {$path}", 'INFO'); - $updated++; - } catch (\Exception $e) { - $this->log(" โœ— {$path}: " . $e->getMessage(), 'ERROR'); - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - } - - $this->log(" {$updated} file(s) synced", 'INFO'); - $success++; - } catch (\Exception $e) { - $this->log(" โœ— {$name}: " . $e->getMessage(), 'ERROR'); - $failed++; - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - } - - $this->log("\n" . str_repeat('=', 50), 'INFO'); - $this->log("Sync complete: {$success} succeeded, {$failed} failed", 'INFO'); - - return $failed > 0 ? 1 : 0; - } - - // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function findTemplateRepos(string $org): array - { - $allRepos = $this->adapter->listOrgRepos($org, true); - $templates = []; - - foreach ($allRepos as $repo) { - try { - $topics = $this->adapter->getRepoTopics($org, $repo['name']); - if (in_array('joomla-template', $topics, true)) { - $templates[] = $repo; - } - } catch (\Exception $e) { - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - } - - return $templates; - } - - private function deriveShortName(string $name): string - { - // MokoTheme โ†’ mokotheme, Moko-Dark-Theme โ†’ mokodarktheme - return strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $name)); - } - - private function printScaffoldPlan(string $shortName): void - { - $files = [ - 'templateDetails.xml', - 'updates.xml', - 'src/index.php', - 'src/error.php', - 'src/offline.php', - 'src/component.php', - 'src/html/index.html', - 'src/css/.gitkeep', - 'src/js/.gitkeep', - 'src/images/.gitkeep', - "src/language/en-GB/tpl_{$shortName}.ini", - "src/language/en-GB/tpl_{$shortName}.sys.ini", - 'media/css/.gitkeep', - 'media/js/.gitkeep', - 'media/images/.gitkeep', - 'media/scss/.gitkeep', - '.editorconfig', - ]; - - $this->log("\nFiles that would be created:", 'INFO'); - foreach ($files as $f) { - $this->log(" + {$f}", 'INFO'); - } - } - - /** - * Generate the full set of scaffold files for a new template. - * - * @return array path => content - */ - private function getScaffoldFiles(string $name, string $shortName, string $client, string $org): array - { - $element = "tpl_{$shortName}"; - $now = date('Y-m-d'); - - $files = []; - - // templateDetails.xml - $files['templateDetails.xml'] = << - - {$name} - {$now} - Moko Consulting - hello@mokoconsulting.tech - https://mokoconsulting.tech - Copyright (C) 2026 Moko Consulting. All rights reserved. - GPL-3.0-or-later - 1.0.0 - {$name} โ€” Joomla {$client} template by Moko Consulting - - - index.php - component.php - error.php - offline.php - templateDetails.xml - html - css - js - images - language - - - - css - js - images - scss - - - - topbar - navbar - hero - breadcrumbs - sidebar-left - sidebar-right - main-top - main-bottom - footer - debug - - - - - https://git.mokoconsulting.tech/{$org}/{$name}/raw/branch/main/updates.xml - - - https://raw.githubusercontent.com/{$org}/{$name}/main/updates.xml - - - - - -
- - - - - - - - -
-
-
-
- XML; - $files['templateDetails.xml'] = preg_replace('/^\t\t/m', '', $files['templateDetails.xml']); - - // updates.xml โ€” dual-platform download URLs (Gitea primary, GitHub secondary) - $files['updates.xml'] = << - - {$name} - {$name} โ€” Moko Consulting Joomla template - tpl_{$shortName} - template - 1.0.0 - - - https://git.mokoconsulting.tech/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip - - - https://github.com/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip - - - - 8.1 - - - XML; - $files['updates.xml'] = preg_replace('/^\t\t/m', '', $files['updates.xml']); - - // src/index.php - $files['src/index.php'] = <<<'PHP' - getWebAssetManager(); - - ?> - - - - - - - - -
- - -
- - - - -
- - - - -
- -
- -
- - - - - PHP; - $files['src/index.php'] = str_replace('TEMPLATE_SHORT_NAME', $shortName, $files['src/index.php']); - $files['src/index.php'] = preg_replace('/^\t\t/m', '', $files['src/index.php']); - - // src/error.php - $files['src/error.php'] = <<<'PHP' - error->getCode(); - $message = htmlspecialchars($this->error->getMessage(), ENT_QUOTES, 'UTF-8'); - - ?> - - - - - - <?php echo $code; ?> โ€” <?php echo $message; ?> - - - - - - PHP; - $files['src/error.php'] = preg_replace('/^\t\t/m', '', $files['src/error.php']); - - // src/offline.php - $files['src/offline.php'] = <<<'PHP' - - - - - - - <?php echo htmlspecialchars($app->get('sitename')); ?> โ€” Maintenance - - -
-

get('sitename')); ?>

-

get('offline_message', 'This site is currently undergoing maintenance. Please check back soon.'); ?>

- -
- - - - - - -
-
- - - PHP; - $files['src/offline.php'] = preg_replace('/^\t\t/m', '', $files['src/offline.php']); - - // src/component.php - $files['src/component.php'] = <<<'PHP' - - - - - - - - - - - - PHP; - $files['src/component.php'] = preg_replace('/^\t\t/m', '', $files['src/component.php']); - - // Directory keepfiles - $files['src/html/index.html'] = ''; - $files['src/css/.gitkeep'] = ''; - $files['src/js/.gitkeep'] = ''; - $files['src/images/.gitkeep'] = ''; - $files['media/css/.gitkeep'] = ''; - $files['media/js/.gitkeep'] = ''; - $files['media/images/.gitkeep'] = ''; - $files['media/scss/.gitkeep'] = ''; - - // Language files - $files["src/language/en-GB/{$element}.ini"] = "; {$name} language strings\n"; - $files["src/language/en-GB/{$element}.sys.ini"] = - "; {$name} system language strings\n" - . "{$element}=\"{$name}\"\n" - . "{$element}_XML_DESCRIPTION=\"{$name} Joomla template by Moko Consulting\"\n"; - - // .editorconfig - $repoRoot = dirname(__DIR__, 2); - $editorConfig = "{$repoRoot}/templates/configs/.editorconfig"; - if (file_exists($editorConfig)) { - $files['.editorconfig'] = file_get_contents($editorConfig) ?: ''; - } - - return $files; - } - - /** - * Get files to sync to existing template repos (standards-only, no template code). - * - * @return array path => content - */ - private function getSyncFiles(string $name, string $shortName): array - { - $repoRoot = dirname(__DIR__, 2); - $files = []; - - // Sync standards files from templates/ - $standardsFiles = [ - 'SECURITY.md' => 'templates/docs/required/template-SECURITY.md', - 'CODE_OF_CONDUCT.md' => 'templates/docs/extra/template-CODE_OF_CONDUCT.md', - 'CONTRIBUTING.md' => 'templates/docs/required/template-CONTRIBUTING.md', - '.editorconfig' => 'templates/configs/.editorconfig', - ]; - - foreach ($standardsFiles as $dest => $source) { - $fullPath = "{$repoRoot}/{$source}"; - if (file_exists($fullPath)) { - $files[$dest] = file_get_contents($fullPath) ?: ''; - } - } - - return $files; - } - - // โ”€โ”€ Sync updates.xml between platforms โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - /** - * Sync updates.xml (or updates.xml) between Gitea and GitHub for Joomla repos. - * - * Reads the file from both platforms, compares by latest tag, - * and pushes the newer one to the stale platform. - * - * Designed to be called from a CI workflow via: - * php automation/bulk_joomla_template.php --sync-updates --repos=MokoCassiopeia - */ - private function syncUpdatesBetweenPlatforms(string $org): int - { - $repos = []; - - if ($this->getArgument('--all', false)) { - $repos = $this->findTemplateRepos($org); - // Also include waas-component repos - $allRepos = $this->adapter->listOrgRepos($org, true); - foreach ($allRepos as $repo) { - try { - $topics = $this->adapter->getRepoTopics($org, $repo['name']); - if (in_array('joomla', $topics, true) || in_array('joomla-extension', $topics, true)) { - $repos[] = $repo; - } - } catch (\Exception $e) { - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - } - // Deduplicate - $seen = []; - $repos = array_filter($repos, function ($r) use (&$seen) { - if (isset($seen[$r['name']])) { - return false; - } - $seen[$r['name']] = true; - return true; - }); - } else { - $reposArg = $this->getArgument('--repos', ''); - if (empty($reposArg)) { - $this->log("โŒ --repos or --all required for --sync-updates", 'ERROR'); - return 1; - } - $names = array_filter(array_map('trim', explode(',', $reposArg))); - foreach ($names as $name) { - $repos[] = ['name' => $name]; - } - } - - if (empty($repos)) { - $this->log("No Joomla repositories to sync updates for", 'INFO'); - return 0; - } - - // Create both platform adapters - try { - $adapters = PlatformAdapterFactory::createBoth($this->config); - } catch (\Exception $e) { - $this->log("โŒ Both platform tokens required for --sync-updates: " . $e->getMessage(), 'ERROR'); - return 1; - } - - $gitea = $adapters['gitea']; - $github = $adapters['github']; - $dryRun = $this->dryRun; - - $this->log("\nSyncing updates.xml across Gitea <-> GitHub for " . count($repos) . " repo(s)...", 'INFO'); - - $synced = 0; - $failed = 0; - - foreach ($repos as $repo) { - $name = $repo['name']; - $this->log("\n[{$name}]", 'INFO'); - - // Try both updates.xml and updates.xml filenames - $updateFile = $this->resolveUpdateFile($gitea, $github, $org, $name); - if ($updateFile === null) { - $this->log(" โŠ˜ No update(s).xml found on either platform", 'INFO'); - continue; - } - - $fileName = $updateFile['name']; - $source = $updateFile['source']; // 'gitea' or 'github' - $content = $updateFile['content']; - $target = $source === 'gitea' ? 'github' : 'gitea'; - $targetAdapter = $source === 'gitea' ? $github : $gitea; - - $this->log(" Source: {$source} ({$fileName})", 'INFO'); - - if ($dryRun) { - $this->log(" (dry-run) Would push {$fileName} to {$target}", 'INFO'); - $synced++; - continue; - } - - // Push to the other platform - try { - $existingSha = null; - try { - $existing = $targetAdapter->getFileContents($org, $name, $fileName); - $existingSha = $existing['sha'] ?? null; - - // Compare content โ€” skip if identical - $existingContent = base64_decode($existing['content'] ?? ''); - if (trim($existingContent) === trim($content)) { - $this->log(" โœ“ Already in sync", 'INFO'); - $synced++; - continue; - } - } catch (\Exception $e) { - $targetAdapter->getApiClient()->resetCircuitBreaker(); - } - - $targetAdapter->createOrUpdateFile( - $org, - $name, - $fileName, - $content, - "chore: sync {$fileName} from {$source}", - $existingSha - ); - $this->log(" โœ“ Pushed to {$target}", 'INFO'); - $synced++; - } catch (\Exception $e) { - $this->log(" โœ— Failed to push to {$target}: " . $e->getMessage(), 'ERROR'); - $targetAdapter->getApiClient()->resetCircuitBreaker(); - $failed++; - } - } - - $this->log("\n" . str_repeat('=', 50), 'INFO'); - $this->log("Updates sync complete: {$synced} synced, {$failed} failed", 'INFO'); - - return $failed > 0 ? 1 : 0; - } - - /** - * Find the updates file on both platforms, return the one with the higher version. - * - * Checks both `updates.xml` and `updates.xml` filenames. - * Returns the content from the platform with the newer . - * Gitea wins ties (primary platform). - * - * @return array{name: string, source: string, content: string}|null - */ - private function resolveUpdateFile( - GitPlatformAdapter $gitea, - GitPlatformAdapter $github, - string $org, - string $name - ): ?array { - $candidates = ['updates.xml', 'updates.xml']; - $found = []; // platform => [name, content, version] - - foreach (['gitea' => $gitea, 'github' => $github] as $platform => $adapter) { - foreach ($candidates as $fileName) { - try { - $file = $adapter->getFileContents($org, $name, $fileName); - $content = base64_decode($file['content'] ?? ''); - - // Extract latest version from the XML - $version = '0.0.0'; - if (preg_match('/([^<]+)<\/version>/', $content, $m)) { - $version = trim($m[1]); - } - - $found[$platform] = [ - 'name' => $fileName, - 'content' => $content, - 'version' => $version, - ]; - break; // Found one โ€” stop checking other filenames for this platform - } catch (\Exception $e) { - $adapter->getApiClient()->resetCircuitBreaker(); - } - } - } - - if (empty($found)) { - return null; - } - - // If only one platform has it, that's the source - if (count($found) === 1) { - $platform = array_key_first($found); - return [ - 'name' => $found[$platform]['name'], - 'source' => $platform, - 'content' => $found[$platform]['content'], - ]; - } - - // Both have it โ€” pick the one with the higher version (Gitea wins ties) - $giteaVer = $found['gitea']['version']; - $githubVer = $found['github']['version']; - - $source = version_compare($githubVer, $giteaVer, '>') ? 'github' : 'gitea'; - - return [ - 'name' => $found[$source]['name'], - 'source' => $source, - 'content' => $found[$source]['content'], - ]; - } -} - -// Execute if run directly -if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) { - $app = new BulkJoomlaTemplate(); - exit($app->execute()); -} diff --git a/automation/bulk_sync.php b/automation/bulk_sync.php deleted file mode 100755 index efcedd3..0000000 --- a/automation/bulk_sync.php +++ /dev/null @@ -1,1428 +0,0 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoPlatform.Automation - * INGROUP: MokoPlatform.Scripts - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli - * PATH: /automation/bulk_sync.php - * BRIEF: Enterprise-grade bulk repository synchronization - */ - -declare(strict_types=1); - -require_once __DIR__ . '/../vendor/autoload.php'; -require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; - -use MokoCli\{ - ApiClient, - AuditLogger, - CheckpointManager, - CircuitBreakerOpen, - CliFramework, - Config, - GitPlatformAdapter, - MetricsCollector, - PlatformAdapterFactory, - PluginFactory, - ProjectTypeDetector, - RateLimitExceeded, - RepositorySynchronizer, - SecurityValidator, - SynchronizationNotImplementedException -}; - -/** - * Bulk Repository Synchronization Tool - * - * Synchronizes mokocli files across multiple repositories using - * the Enterprise library for robust, audited operations. - */ -class BulkSync extends CliFramework -{ - /** - * Default organization for bulk sync operations - * Public to allow script instantiation with class constants - */ - public const DEFAULT_ORG = 'MokoConsulting'; - - /** - * Script version number - * Public to allow script instantiation with class constants - */ - public const VERSION = '09.23.00'; - public const VERSION_MINOR = '04.05'; - - private ApiClient $api; - private GitPlatformAdapter $adapter; - private RepositorySynchronizer $synchronizer; - private AuditLogger $logger; - private CheckpointManager $checkpoints; - private MetricsCollector $metrics; - private Config $config; - - /** Set to true by signal handler or rate-limit detection to abort the sync loop gracefully. */ - private bool $interrupted = false; - - /** - * Setup command-line arguments - */ - protected function configure(): void - { - $this->setDescription('Bulk repository synchronization'); - $this->addArgument('--org', 'Organization', self::DEFAULT_ORG); - $this->addArgument('--repos', 'Specific repos', ''); - $this->addArgument('--exclude', 'Repos to exclude', ''); - $this->addArgument('--skip-archived', 'Skip archived repos', false); - $this->addArgument('--yes', 'Auto-confirm', false); - $this->addArgument('--resume', 'Resume from checkpoint', false); - $this->addArgument('--force', 'Force overwrite', false); - $this->addArgument('--protect', 'Apply branch protection', false); - $this->addArgument('--no-issue', 'Skip tracking issue', false); - $this->addArgument('--update-branches', 'Merge main into branches', false); - $this->addArgument('--health', 'Run health checks', false); - } - - /** - * Main execution - */ - protected function run(): int - { - $this->log("๐Ÿš€ mokocli Bulk Synchronization v" . self::VERSION, 'INFO'); - - // Initialize enterprise components - if (!$this->initializeComponents()) { - return 1; - } - - // Get configuration - $org = $this->getArgument('--org', self::DEFAULT_ORG); - $skipArchived = $this->getArgument('--skip-archived', false); - $autoConfirm = $this->getArgument('--yes', false); - - // Get repository filters - $specificRepos = $this->parseRepositoryList($this->getArgument('--repos', '')); - $excludeRepos = $this->parseRepositoryList($this->getArgument('--exclude', '')); - - $this->log("Organization: {$org}", 'INFO'); - if (!empty($specificRepos)) { - $this->log("Repositories: " . implode(', ', $specificRepos), 'INFO'); - } - if (!empty($excludeRepos)) { - $this->log("Excluding: " . implode(', ', $excludeRepos), 'INFO'); - } - - // Get repositories - $this->log("๐Ÿ“‹ Fetching repositories...", 'INFO'); - $repositories = $this->synchronizer->getRepositories($org, $skipArchived); - - // Apply filters - $repositories = $this->filterRepositories($repositories, $specificRepos, $excludeRepos); - - $count = count($repositories); - $this->log("Found {$count} repositories to sync", 'INFO'); - - if ($count === 0) { - $this->log("No repositories to process", 'WARN'); - return 0; - } - - // Load resume checkpoint if --resume is set - $alreadyProcessed = []; - if ($this->getArgument('--resume', false)) { - $checkpoint = $this->checkpoints->loadCheckpoint('bulk_sync'); - if ($checkpoint !== null) { - $alreadyProcessed = array_keys($checkpoint['results']['repositories'] ?? []); - $skipCount = count($alreadyProcessed); - $stoppedAt = $checkpoint['stopped_at'] ?? 'unknown'; - $reason = $checkpoint['stopped_reason'] ?? 'unknown'; - $this->log("โ–ถ Resuming from checkpoint ({$reason} at '{$stoppedAt}') โ€” skipping {$skipCount} already-processed repositories", 'INFO'); - } else { - $this->log("โš ๏ธ No checkpoint found, starting from scratch", 'WARN'); - } - } - - // Confirm before proceeding - $remaining = $count - count($alreadyProcessed); - if (!$autoConfirm && !$this->confirmSync($remaining > 0 ? $remaining : $count)) { - $this->log("โŒ Sync cancelled by user", 'INFO'); - return 0; - } - - // Sync universal workflows from Template-Generic โ†’ other templates first - $this->log("๐Ÿ“‹ Syncing universal workflows to template repos...", 'INFO'); - $templateUpdates = $this->synchronizer->syncUniversalWorkflowsToTemplates($org); - $this->log("Template sync: {$templateUpdates} file(s) updated", 'INFO'); - - // Execute synchronization - $this->log("๐Ÿ”„ Starting synchronization...", 'INFO'); - $results = $this->executeSynchronization($org, $repositories, $alreadyProcessed); - - // Display results - $this->displayResults($results); - - // Apply branch protection if --protect flag is set - if (isset($this->options['protect'])) { - $this->log("๐Ÿ”’ Applying branch protection rules...", 'INFO'); - $results['protection'] = $this->applyBranchProtectionAll($org, $repositories); - } - - // Run repo health checks if --health flag is set - if (isset($this->options['health'])) { - $this->log("๐Ÿฉบ Running repository health checks...", 'INFO'); - $results['health'] = $this->runHealthChecksAll($org, $repositories); - } - - // Create/update tracking issue in mokocli - $this->createSyncIssue($org, $results); - - // Create/update a failure issue when any repos failed - if ($results['failed'] > 0) { - $this->createFailureIssue($org, $results); - } - - return $results['failed'] > 0 ? 1 : 0; - } - - /** - * Initialize enterprise components - */ - private function initializeComponents(): bool - { - $this->config = Config::load(); - $platform = $this->config->getString('platform', 'github'); - - try { - $this->adapter = PlatformAdapterFactory::create($this->config); - $this->api = $this->adapter->getApiClient(); - - $this->logger = new AuditLogger('bulk_sync'); - $this->metrics = new MetricsCollector(); - $this->checkpoints = new CheckpointManager('.checkpoints'); - $this->synchronizer = new RepositorySynchronizer( - $this->api, - $this->logger, - $this->metrics, - $this->checkpoints, - null, - $this->adapter - ); - - // Initialize plugin system - - $this->log("โœ“ Enterprise components initialized for platform: {$platform}", 'INFO'); - return true; - } catch (\Exception $e) { - $this->log("โŒ Failed to initialize: " . $e->getMessage(), 'ERROR'); - return false; - } - } - - /** - * Parse repository list from string - */ - private function parseRepositoryList(string $input): array - { - if (empty($input)) { - return []; - } - - return array_filter( - array_map('trim', preg_split('/[\s,]+/', $input)), - fn($r) => !empty($r) - ); - } - - /** - * Filter repositories based on include/exclude lists - */ - /** Repositories that are permanently excluded from bulk sync. */ - private const ALWAYS_EXCLUDE = ['mokocli', '.github-private']; - - private function filterRepositories(array $repositories, array $include, array $exclude): array - { - // Apply include filter if specified (but never override permanent exclusions) - if (!empty($include)) { - $repositories = array_filter( - $repositories, - fn($repo) => in_array($repo['name'], $include) - ); - } - - // Merge user excludes with permanent excludes - $allExclude = array_unique(array_merge($exclude, self::ALWAYS_EXCLUDE)); - - $repositories = array_filter( - $repositories, - fn($repo) => !in_array($repo['name'], $allExclude) - ); - - return array_values($repositories); - } - - /** - * Sort repositories so that .github-private is always processed first. - * All other repositories retain their original relative order. - * - * @param array $repositories - * @return array - */ - private function prioritizeRepositories(array $repositories): array - { - $priority = []; - $rest = []; - - foreach ($repositories as $repo) { - if ($repo['name'] === '.github-private') { - $priority[] = $repo; - } else { - $rest[] = $repo; - } - } - - return array_merge($priority, $rest); - } - - /** - * Confirm synchronization with user - */ - private function confirmSync(int $count): bool - { - if ($this->quiet) { - return true; - } - - echo "\nโš ๏ธ About to synchronize {$count} repositories.\n"; - echo "This will update files across all repositories.\n"; - echo "\nContinue? [y/N]: "; - - $handle = fopen("php://stdin", "r"); - $line = fgets($handle); - if ($handle) { - fclose($handle); - } - - // fgets() returns false when stdin is not a TTY (e.g. CI, piped input); - // treat that as a non-confirmation rather than crashing. - return is_string($line) && strtolower(trim($line)) === 'y'; - } - - /** - * Execute synchronization across repositories - * - * @param array $alreadyProcessed Repo names to skip (from a resumed checkpoint) - */ - private function executeSynchronization(string $org, array $repositories, array $alreadyProcessed = []): array - { - $results = [ - 'total' => count($repositories), - 'success' => 0, - 'skipped' => 0, - 'failed' => 0, - 'repositories' => [], - 'prs' => [], - 'issues' => [], - ]; - - // Seed results with repos that were already processed so the final - // summary and issue reflect the full run, not just the resumed portion. - foreach ($alreadyProcessed as $name) { - $results['repositories'][$name] = 'skipped (resumed)'; - $results['skipped']++; - } - - // Register signal handlers so Ctrl-C / SIGTERM saves a resume checkpoint - // instead of leaving the run in an unknown state. - if (function_exists('pcntl_async_signals')) { - pcntl_async_signals(true); - pcntl_signal(SIGINT, function () { - $this->interrupted = true; - }); - pcntl_signal(SIGTERM, function () { - $this->interrupted = true; - }); - } - - $startTime = microtime(true); - - foreach ($repositories as $index => $repo) { - $repoName = $repo['name']; - $progress = $index + 1; - $total = $results['total']; - - // Skip repos already covered by a previous partial run - if (in_array($repoName, $alreadyProcessed, true)) { - $this->log("[{$progress}/{$total}] โŠ˜ {$repoName} (already processed โ€” skipping)", 'INFO'); - continue; - } - - // Check for Ctrl-C / SIGTERM before starting each repo - if ($this->interrupted) { - $this->log("โšก Interrupted before {$repoName} โ€” saving checkpoint", 'WARN'); - $this->saveInterruptCheckpoint($results, $repoName, 'interrupted'); - break; - } - - $this->log("[{$progress}/{$total}] Processing {$repoName}...", 'INFO'); - - // Reset circuit breaker before processing each repository - // This prevents failures on one repo from blocking subsequent repos - $this->api->resetCircuitBreaker(); - - // Ensure standard labels exist on the target repo before syncing. - // Label provisioning is non-critical โ€” if the circuit breaker trips - // (e.g. 54 new labels on a fresh repo), reset it so file sync proceeds. - if (!$this->dryRun) { - $this->ensureRepoLabels($org, $repoName); - $this->ensureReleaseTags($org, $repoName); - $this->api->resetCircuitBreaker(); - } - - try { - $updated = $this->synchronizer->processRepository( - $org, - $repoName, - $this->dryRun, - isset($this->options['force']) - ); - - if ($updated !== false && $updated > 0) { - $results['success']++; - $results['repositories'][$repoName] = 'success'; - $results['prs'][$repoName] = $updated; - $this->log(" โœ“ {$repoName} updated", 'INFO'); - if (!isset($this->options['no-issue']) && !$this->dryRun) { - $issueNum = $this->createTargetRepoIssue($org, $repoName, $updated); - if ($issueNum !== null) { - $results['issues'][$repoName] = $issueNum; - } - } - if (isset($this->options['update-branches']) && !$this->dryRun) { - $this->updateOpenBranches($org, $repoName); - } - } else { - $results['skipped']++; - $results['repositories'][$repoName] = 'skipped'; - $this->log(" โŠ˜ {$repoName} skipped", 'INFO'); - } - } catch (SynchronizationNotImplementedException $e) { - $this->log("", 'ERROR'); - $this->log("โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—", 'ERROR'); - $this->log("โ•‘ CRITICAL ERROR: Repository Synchronization Not Implemented โ•‘", 'ERROR'); - $this->log("โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•", 'ERROR'); - $this->log("", 'ERROR'); - $this->log("The bulk repository sync is failing silently because the core", 'ERROR'); - $this->log("synchronization logic has not been implemented yet.", 'ERROR'); - $this->log("", 'ERROR'); - $this->log("Location: lib/Enterprise/RepositorySynchronizer.php", 'ERROR'); - $this->log("Method: processRepository()", 'ERROR'); - $this->log("", 'ERROR'); - $this->log("Required Implementation:", 'ERROR'); - $this->log(" 1. Clone/fetch target repository", 'ERROR'); - $this->log(" 2. Apply file updates based on mokocli configuration", 'ERROR'); - $this->log(" 3. Create pull request with changes", 'ERROR'); - $this->log(" 4. Handle merge conflicts and validation", 'ERROR'); - $this->log("", 'ERROR'); - $this->log("Until this is implemented, bulk sync will not function.", 'ERROR'); - $this->log("", 'ERROR'); - throw $e; - } catch (CircuitBreakerOpen $e) { - $results['failed']++; - $results['repositories'][$repoName] = 'failed'; - $this->log(" โœ— {$repoName} failed: Circuit breaker open - " . $e->getMessage(), 'ERROR'); - } catch (RateLimitExceeded $e) { - // Rate limit hit โ€” abort immediately so we don't burn retries on 403s - $results['failed']++; - $results['repositories'][$repoName] = 'failed'; - $this->log(" โœ— {$repoName} rate-limited: " . $e->getMessage(), 'ERROR'); - $this->saveInterruptCheckpoint($results, $repoName, 'rate_limited'); - break; - } catch (\Exception $e) { - // Also catch rate limits surfaced as generic exceptions by ApiClient retries - if ($this->isRateLimitError($e)) { - $results['failed']++; - $results['repositories'][$repoName] = 'failed'; - $this->log(" โœ— {$repoName} rate-limited โ€” stopping sync", 'ERROR'); - $this->saveInterruptCheckpoint($results, $repoName, 'rate_limited'); - break; - } - $results['failed']++; - $results['repositories'][$repoName] = 'failed'; - $this->log(" โœ— {$repoName} failed: " . $e->getMessage(), 'ERROR'); - } - - // Save rolling checkpoint after each repo (skipped in dry-run) - if (!$this->dryRun) { - $this->checkpoints->saveCheckpoint('bulk_sync', [ - 'processed' => $progress, - 'total' => $total, - 'results' => $results, - 'stopped_at' => $repoName, - 'stopped_reason' => 'checkpoint', - ]); - } - } - - $duration = microtime(true) - $startTime; - $results['duration'] = $duration; - - return $results; - } - - /** - * Return true when an exception message indicates a GitHub rate-limit response. - * Catches 403 rate-limit errors that ApiClient wraps as generic exceptions. - */ - private function isRateLimitError(\Exception $e): bool - { - $msg = strtolower($e->getMessage()); - return str_contains($msg, 'rate limit') || str_contains($msg, '429') - || (str_contains($msg, '403') && str_contains($msg, 'rate')); - } - - /** - * Save a checkpoint that records where the sync was interrupted, then print - * a hint showing the exact command needed to resume. - */ - private function saveInterruptCheckpoint(array $results, string $stoppedAt, string $reason): void - { - if ($this->dryRun) { - return; - } - - try { - $this->checkpoints->saveCheckpoint('bulk_sync', [ - 'processed' => count($results['repositories']), - 'total' => $results['total'], - 'results' => $results, - 'stopped_at' => $stoppedAt, - 'stopped_reason' => $reason, - ]); - $script = basename(__FILE__); - $this->log("๐Ÿ’พ Checkpoint saved. To resume once the issue is resolved, run:", 'INFO'); - $this->log(" php automation/{$script} --resume [same flags as before]", 'INFO'); - } catch (\Exception $e) { - $this->log("โš ๏ธ Failed to save interrupt checkpoint: " . $e->getMessage(), 'WARN'); - } - } - - /** - * Display synchronization results - */ - private function displayResults(array $results): void - { - $this->log("\n" . str_repeat('=', 60), 'INFO'); - $this->log("๐Ÿ“Š Synchronization Complete", 'INFO'); - $this->log(str_repeat('=', 60), 'INFO'); - - $total = $results['total']; - $success = $results['success']; - $skipped = $results['skipped']; - $failed = $results['failed']; - $duration = $results['duration']; - - $successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0; - - $this->log(sprintf("Total: %d repositories", $total), 'INFO'); - $this->log(sprintf("Success: %d (โœ“)", $success), 'INFO'); - $this->log(sprintf("Skipped: %d (โŠ˜)", $skipped), 'INFO'); - $this->log(sprintf("Failed: %d (โœ—)", $failed), 'INFO'); - $this->log(sprintf("Success Rate: %.1f%%", $successRate), 'INFO'); - $this->log(sprintf("Duration: %.2f seconds", $duration), 'INFO'); - - if ($failed > 0) { - $this->log("\nโš ๏ธ Failed Repositories:", 'WARN'); - foreach ($results['repositories'] as $repo => $status) { - if ($status === 'failed') { - $this->log(" - {$repo}", 'WARN'); - } - } - } - - if ($this->verbose) { - $this->log("\n๐Ÿ“‹ Repository Details:", 'INFO'); - foreach ($results['repositories'] as $repo => $status) { - $icon = match ($status) { - 'success' => 'โœ“', - 'skipped' => 'โŠ˜', - 'failed' => 'โœ—', - default => '?' - }; - $this->log(sprintf(" %s %s: %s", $icon, $repo, $status), 'INFO'); - } - } - - $this->log(str_repeat('=', 60), 'INFO'); - - $this->writeStepSummary($results); - } - - /** - * Write synchronization results to the GitHub Actions step summary. - * - * Appends a Markdown summary table listing every repository that was - * processed โ€” together with its outcome (updated, skipped, or failed) โ€” - * to the file referenced by the GITHUB_STEP_SUMMARY environment variable. - * When that variable is not set (e.g. local runs) the method is a no-op. - */ - private function writeStepSummary(array $results): void - { - // Check both GitHub and Gitea step summary env vars - $summaryFile = getenv($this->adapter->getStepSummaryEnvVar()); - if (empty($summaryFile)) { - // Fallback: also check the other platform's env var - $fallback = $this->adapter->getPlatformName() === 'github' - ? getenv('GITEA_STEP_SUMMARY') - : getenv('GITHUB_STEP_SUMMARY'); - $summaryFile = $fallback ?: ''; - } - if (empty($summaryFile)) { - return; - } - - // Validate that the path is an absolute filesystem path and not a - // special device file, to guard against environment variable injection. - $realDir = realpath(dirname($summaryFile)); - if ($realDir === false || !str_starts_with($summaryFile, '/') || strpos($summaryFile, '..') !== false) { - $this->log('โš ๏ธ GITHUB_STEP_SUMMARY path is not safe, skipping step summary write.', 'WARN'); - return; - } - - $total = $results['total']; - $success = $results['success']; - $skipped = $results['skipped']; - $failed = $results['failed']; - $duration = $results['duration']; - $successRate = $total > 0 ? round(($success / $total) * 100, 1) : 0; - - $lines = []; - $lines[] = ''; - $lines[] = '### ๐Ÿ“Š Synchronization Summary'; - $lines[] = ''; - $lines[] = '| Total | โœ… Updated | โŠ˜ Skipped | โŒ Failed | Success Rate | Duration |'; - $lines[] = '|------:|----------:|----------:|----------:|-------------:|---------:|'; - $lines[] = sprintf( - '| %d | %d | %d | %d | %.1f%% | %.2fs |', - $total, - $success, - $skipped, - $failed, - $successRate, - $duration - ); - $lines[] = ''; - - if (!empty($results['repositories'])) { - $lines[] = '### ๐Ÿ“‹ Repositories Processed'; - $lines[] = ''; - $lines[] = '| Repository | Status |'; - $lines[] = '|:-----------|:-------|'; - foreach ($results['repositories'] as $repo => $status) { - $label = match ($status) { - 'success' => 'โœ… Updated', - 'skipped' => 'โŠ˜ Skipped', - 'failed' => 'โŒ Failed', - default => $status, - }; - $lines[] = sprintf('| `%s` | %s |', $repo, $label); - } - $lines[] = ''; - } - - $written = file_put_contents($summaryFile, implode("\n", $lines) . "\n", FILE_APPEND); - if ($written === false) { - $this->log('โš ๏ธ Failed to write to GITHUB_STEP_SUMMARY.', 'WARN'); - } - } - - /** - * Apply main branch protection to all repositories. - * - * Tries classic branch protection first; records the outcome per repo. - * Private repos on the free GitHub plan will receive a 403 โ€” those are - * noted but do not count as failures. - * - * @param array $repositories - * @return array repo name => 'protected'|'skipped'|'no_main'|'plan_limit'|'error' - */ - private function applyBranchProtectionAll(string $org, array $repositories): array - { - $protection = []; - - foreach ($repositories as $repo) { - $name = $repo['name']; - - if ($this->dryRun) { - $this->log(" (dry-run) would protect {$name}/main", 'INFO'); - $protection[$name] = 'skipped'; - continue; - } - - try { - $this->adapter->setBranchProtection($org, $name, 'main', [ - 'required_reviews' => 1, - 'dismiss_stale' => false, - 'enforce_admins' => false, - 'require_code_owner' => false, - ]); - $protection[$name] = 'protected'; - $this->log(" ๐Ÿ”’ {$name}: main branch protected", 'INFO'); - } catch (\Exception $e) { - $msg = $e->getMessage(); - if (str_contains($msg, '403')) { - $protection[$name] = 'plan_limit'; - $this->log(" โš ๏ธ {$name}: branch protection requires upgraded plan (private repo)", 'WARN'); - } elseif (str_contains($msg, '404')) { - $protection[$name] = 'no_main'; - $this->log(" โŠ˜ {$name}: no main branch found", 'INFO'); - } else { - $protection[$name] = 'error'; - $this->log(" โœ— {$name}: {$msg}", 'ERROR'); - } - $this->api->resetCircuitBreaker(); - } - } - - $protectedCount = count(array_filter($protection, fn($v) => $v === 'protected')); - $planLimitCount = count(array_filter($protection, fn($v) => $v === 'plan_limit')); - $this->log(sprintf( - "๐Ÿ”’ Branch protection: %d protected, %d require upgraded plan", - $protectedCount, - $planLimitCount - ), 'INFO'); - - return $protection; - } - - /** - * Run lightweight health checks on all repositories after sync. - * - * Checks rulesets (MAIN, VERSION, DEV) and branch protection via the GitHub API. - * Returns a map of repo name => ['score' => int, 'max' => int, 'level' => string]. - * - * @param array $repositories - * @return array - */ - private function runHealthChecksAll(string $org, array $repositories): array - { - $health = []; - - foreach ($repositories as $repo) { - $name = $repo['name']; - $score = 0; - $max = 0; - - // 1. Check branch protection rules (rulesets on GitHub, branch_protections on Gitea) - $max += 20; - try { - $protections = $this->adapter->listBranchProtections($org, $name); - $hasMain = $hasVersion = $hasDev = $hasRc = false; - - foreach ($protections as $prot) { - $protName = strtolower($prot['name'] ?? $prot['branch_name'] ?? ''); - $refs = $prot['conditions']['ref_name']['include'] ?? []; - - if (str_contains($protName, 'main') || in_array('refs/heads/main', $refs, true)) { - $hasMain = true; - } - if (str_contains($protName, 'version') || $this->refsContain($refs, 'version')) { - $hasVersion = true; - } - if ( - (str_contains($protName, 'dev') && !str_contains($protName, 'develop')) - || $this->refsContain($refs, 'dev') - ) { - $hasDev = true; - } - if (str_contains($protName, 'rc') || $this->refsContain($refs, 'rc/')) { - $hasRc = true; - } - } - - if ($hasMain) { - $score += 5; - } - if ($hasVersion) { - $score += 5; - } - if ($hasDev) { - $score += 5; - } - if ($hasRc) { - $score += 5; - } - } catch (\Exception $e) { - $this->api->resetCircuitBreaker(); - } - - // 2. Check branch protection on main (10 pts) - $max += 10; - $hasMainProtection = $this->checkBranchProtected($org, $name); - if ($hasMainProtection) { - $score += 10; - } - - // Calculate level - $pct = $max > 0 ? ($score / $max * 100) : 0; - $level = match (true) { - $pct >= 90 => 'excellent', - $pct >= 70 => 'good', - $pct >= 50 => 'fair', - default => 'poor', - }; - - $health[$name] = ['score' => $score, 'max' => $max, 'level' => $level]; - - if ($pct < 70) { - $this->log(" โš ๏ธ {$name}: health {$score}/{$max} ({$level})", 'WARN'); - } else { - $this->log(" โœ“ {$name}: health {$score}/{$max} ({$level})", 'INFO'); - } - } - - $excellent = count(array_filter($health, fn($h) => $h['level'] === 'excellent')); - $good = count(array_filter($health, fn($h) => $h['level'] === 'good')); - $fair = count(array_filter($health, fn($h) => $h['level'] === 'fair')); - $poor = count(array_filter($health, fn($h) => $h['level'] === 'poor')); - $this->log(sprintf( - "๐Ÿฉบ Health: %d excellent, %d good, %d fair, %d poor", - $excellent, - $good, - $fair, - $poor - ), 'INFO'); - - return $health; - } - - /** - * Check if any ref patterns in the array contain a given keyword. - */ - private function refsContain(array $refs, string $keyword): bool - { - foreach ($refs as $ref) { - if (str_contains($ref, $keyword)) { - return true; - } - } - return false; - } - - /** - * Check if a repo's main branch has protection enabled. - * - * @return bool True if main branch is protected - */ - private function checkBranchProtected(string $org, string $repo): bool - { - try { - $protections = $this->adapter->listBranchProtections($org, $repo); - foreach ($protections as $prot) { - $name = strtolower($prot['name'] ?? $prot['branch_name'] ?? ''); - if (str_contains($name, 'main')) { - return true; - } - } - } catch (\Exception $e) { - $this->api->resetCircuitBreaker(); - } - return false; - } - - /** - * Ensure all standard mokocli labels exist on a target repository. - * - * Fetches existing labels first (GET) and only POSTs the ones that are - * missing. This avoids the 422 "already exists" responses that would - * otherwise accumulate and trip the circuit breaker on subsequent runs. - */ - private function ensureRepoLabels(string $org, string $repo): void - { - /** @var list name, hex colour (no #), description */ - $labels = [ - // Project Type - ['joomla', '7F52FF', 'Joomla extension or component'], - ['dolibarr', 'FF6B6B', 'Dolibarr module or extension'], - ['generic', '808080', 'Generic project or library'], - - // Language - ['php', '4F5D95', 'PHP code changes'], - ['javascript', 'F7DF1E', 'JavaScript code changes'], - ['typescript', '3178C6', 'TypeScript code changes'], - ['python', '3776AB', 'Python code changes'], - ['css', '1572B6', 'CSS/styling changes'], - ['html', 'E34F26', 'HTML template changes'], - - // Component - ['documentation', '0075CA', 'Documentation changes'], - ['ci-cd', '000000', 'CI/CD pipeline changes'], - ['docker', '2496ED', 'Docker configuration changes'], - ['tests', '00FF00', 'Test suite changes'], - ['security', 'FF0000', 'Security-related changes'], - ['dependencies', '0366D6', 'Dependency updates'], - ['config', 'F9D0C4', 'Configuration file changes'], - ['build', 'FFA500', 'Build system changes'], - - // Workflow / Process - ['automation', '8B4513', 'Automated processes or scripts'], - ['mokocli', 'B60205', 'mokocli compliance'], - ['needs-review', 'FBCA04', 'Awaiting code review'], - ['work-in-progress', 'D93F0B', 'Work in progress, not ready for merge'], - ['breaking-change', 'D73A4A', 'Breaking API or functionality change'], - - // Priority - ['priority: critical', 'B60205', 'Critical priority, must be addressed immediately'], - ['priority: high', 'D93F0B', 'High priority'], - ['priority: medium', 'FBCA04', 'Medium priority'], - ['priority: low', '0E8A16', 'Low priority'], - - // Type - ['type: bug', 'D73A4A', "Something isn't working"], - ['type: feature', 'A2EEEF', 'New feature or request'], - ['type: enhancement', '84B6EB', 'Enhancement to existing feature'], - ['type: refactor', 'F9D0C4', 'Code refactoring'], - ['type: chore', 'FEF2C0', 'Maintenance tasks'], - - // Status - ['status: pending', 'FBCA04', 'Pending action or decision'], - ['status: in-progress', '0E8A16', 'Currently being worked on'], - ['status: blocked', 'B60205', 'Blocked by another issue or dependency'], - ['status: on-hold', 'D4C5F9', 'Temporarily on hold'], - ['status: wontfix', 'FFFFFF', 'This will not be worked on'], - - // Size - ['size/xs', 'C5DEF5', 'Extra small change (1-10 lines)'], - ['size/s', '6FD1E2', 'Small change (11-30 lines)'], - ['size/m', 'F9DD72', 'Medium change (31-100 lines)'], - ['size/l', 'FFA07A', 'Large change (101-300 lines)'], - ['size/xl', 'FF6B6B', 'Extra large change (301-1000 lines)'], - ['size/xxl', 'B60205', 'Extremely large change (1000+ lines)'], - - // Health - ['health: excellent', '0E8A16', 'Health score 90-100'], - ['health: good', 'FBCA04', 'Health score 70-89'], - ['health: fair', 'FFA500', 'Health score 50-69'], - ['health: poor', 'FF6B6B', 'Health score below 50'], - - // Sync / Automation (used by bulk_sync, scan_drift, check_repo_health) - ['standards-update', 'B60205', 'mokocli sync update'], - ['standards-drift', 'FBCA04', 'Repository drifted from mokocli'], - ['sync-report', '0075CA', 'Bulk sync run report'], - ['sync-failure', 'D73A4A', 'Bulk sync failure requiring attention'], - ['push-failure', 'D73A4A', 'File push failure requiring attention'], - ['health-check', '0E8A16', 'Repository health check results'], - ['version-drift', 'FFA500', 'Version mismatch detected'], - ['deploy-failure', 'CC0000', 'Automated deploy failure tracking'], - ['template-validation-failure', 'D73A4A', 'Template workflow validation failure'], - ['version', '0E8A16', 'Version bump or release'], - ['type: version', '0E8A16', 'Version-related change'], - ]; - - // Quick check: if the repo already has the 'mokocli' label, it was - // provisioned previously โ€” skip the expensive full label provisioning. - try { - $probe = $this->api->get("/repos/{$org}/{$repo}/labels/mokocli"); - if (!empty($probe['name'])) { - return; // already provisioned - } - } catch (\Exception $e) { - // Label doesn't exist โ€” proceed with full provisioning - } - - // Fetch existing labels to determine which ones need creating. - $existing = []; - try { - $page = 1; - do { - $page_labels = $this->api->get("/repos/{$org}/{$repo}/labels?per_page=100&page={$page}"); - foreach ($page_labels as $label) { - $existing[strtolower($label['name'])] = true; - } - $page++; - } while (count($page_labels) === 100); - } catch (\Exception $e) { - // Cannot read labels (e.g. no access) โ€” skip provisioning entirely - return; - } - - foreach ($labels as [$name, $color, $description]) { - if (isset($existing[strtolower($name)])) { - continue; // already exists โ€” no POST needed - } - // Reset before each attempt โ€” the circuit breaker checks state at the - // START of each API call, so resetting after a failure is too late. - $this->api->resetCircuitBreaker(); - try { - $this->api->post("/repos/{$org}/{$repo}/labels", [ - 'name' => $name, - 'color' => $color, - 'description' => $description, - ]); - } catch (\Exception $e) { - // Ignore โ€” label already exists or transient failure - } - } - } - - /** - * Ensure standard release tags exist on the repository. - * - * Creates 'development', 'beta', and 'release-candidate' tags pointing - * to the default branch HEAD if they don't already exist. These tags - * are used by the release workflow to track stability channels. - */ - private function ensureReleaseTags(string $org, string $repo): void - { - $requiredTags = ['development', 'beta', 'release-candidate']; - - try { - $existingTags = $this->api->get("/repos/{$org}/{$repo}/tags", ['limit' => 50]); - } catch (\Exception $e) { - return; // Non-critical - } - - $existingNames = array_column($existingTags, 'name'); - - // Get default branch to point new tags at - try { - $repoInfo = $this->api->get("/repos/{$org}/{$repo}"); - $defaultBranch = $repoInfo['default_branch'] ?? 'main'; - } catch (\Exception $e) { - $defaultBranch = 'main'; - } - - foreach ($requiredTags as $tagName) { - if (in_array($tagName, $existingNames, true)) { - continue; - } - - try { - $this->api->post("/repos/{$org}/{$repo}/tags", [ - 'tag_name' => $tagName, - 'target' => $defaultBranch, - 'message' => "Release channel: {$tagName}", - ]); - $this->log(" ๐Ÿท๏ธ Created tag '{$tagName}' on {$repo}", 'INFO'); - } catch (\Exception $e) { - // Non-critical โ€” tag may already exist as a release tag - } - } - } - - /** - * Merge main into all open PR branches (except the sync branch itself). - * - * This ensures feature/development branches stay up to date with the - * latest synced standards after a bulk sync run. - */ - private function updateOpenBranches(string $org, string $repo): void - { - $syncBranchPrefix = 'chore/sync-mokocli-'; - - try { - $defaultBranch = 'main'; - try { - $repoInfo = $this->api->get("/repos/{$org}/{$repo}"); - $defaultBranch = $repoInfo['default_branch'] ?? 'main'; - } catch (\Exception $e) { -/* fallback to main */ - } - - $prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [ - 'state' => 'open', - 'per_page' => 30, - 'sort' => 'updated', - 'direction' => 'desc', - ]); - - foreach ($prs as $pr) { - $branch = $pr['head']['ref'] ?? ''; - $prNum = $pr['number'] ?? 0; - - // Skip sync branches โ€” they were just reset from main - if (str_starts_with($branch, $syncBranchPrefix)) { - continue; - } - - try { - $this->api->post("/repos/{$org}/{$repo}/merges", [ - 'base' => $branch, - 'head' => $defaultBranch, - 'commit_message' => "chore: merge {$defaultBranch} into {$branch} (mokocli sync)", - ]); - $this->log(" ๐Ÿ”€ Merged {$defaultBranch} โ†’ {$branch} (PR #{$prNum})", 'INFO'); - } catch (\Exception $e) { - $msg = $e->getMessage(); - if (str_contains($msg, '409') || str_contains($msg, 'Merge conflict')) { - $this->log(" โš ๏ธ Merge conflict: {$defaultBranch} โ†’ {$branch} (PR #{$prNum})", 'WARN'); - } elseif (str_contains($msg, '204') || str_contains($msg, 'nothing to merge')) { - $this->log(" โœ“ Already up to date: {$branch}", 'DEBUG'); - } else { - $this->log(" โš ๏ธ Could not merge into {$branch}: " . $msg, 'WARN'); - } - } - } - } catch (\Exception $e) { - $this->log(" โš ๏ธ Could not update branches in {$repo}: " . $e->getMessage(), 'WARN'); - } - } - - /** - * Records which sync run touched the repo, the PR number, and the - * mokocli version that was applied โ€” giving each repo a clear audit - * trail of what was changed and why. - */ - /** - * Resolve label names to their integer IDs for the Gitea API. - * Creates missing labels automatically. - * - * @param string $org Organization name - * @param string $repo Repository name - * @param string[] $labelNames Label names to resolve - * @return int[] Array of label IDs - */ - private function resolveLabelIds(string $org, string $repo, array $labelNames): array - { - try { - $existing = $this->api->get("/repos/{$org}/{$repo}/labels", ['limit' => 50]); - } catch (\Exception $e) { - return []; - } - - $nameToId = []; - foreach ($existing as $label) { - $nameToId[$label['name']] = (int) $label['id']; - } - - $ids = []; - foreach ($labelNames as $name) { - if (isset($nameToId[$name])) { - $ids[] = $nameToId[$name]; - } - // Skip labels that don't exist (ensureRepoLabels creates them separately) - } - - return $ids; - } - - private function createTargetRepoIssue(string $org, string $repo, int $prNumber): ?int - { - $now = gmdate('Y-m-d H:i:s') . ' UTC'; - $version = self::VERSION; - $minor = self::VERSION_MINOR; - $force = isset($this->options['force']) ? ' *(--force)*' : ''; - $prLink = $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber); - $source = $this->adapter->getRepoWebUrl($org, 'mokocli'); - $branchName = 'chore/sync-mokocli-v' . $minor; - $branchLink = $this->adapter->getBranchWebUrl($org, $repo, $branchName); - - $title = "chore: mokocli v{$minor} sync tracking"; - - $body = <<resolveLabelIds($org, $repo, $labelNames); - - try { - // Check for an existing tracking issue (any state so we can reopen closed ones) - $existing = $this->api->get("/repos/{$org}/{$repo}/issues", [ - 'labels' => 'standards-update', - 'state' => 'all', - 'per_page' => 1, - 'sort' => 'created', - 'direction' => 'desc', - ]); - $existing = array_values($existing); - - if (!empty($existing) && isset($existing[0]['number'])) { - $num = $existing[0]['number']; - $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; - if (($existing[0]['state'] ?? 'open') === 'closed') { - $patch['state'] = 'open'; - } - $this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch); - // Re-apply labels in case any were removed - try { - $this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]); - } catch (\Exception $le) { -/* non-fatal */ - } - $this->log(" ๐Ÿ“‹ Tracking issue #{$num} updated in {$repo}", 'INFO'); - } else { - $issue = $this->api->post("/repos/{$org}/{$repo}/issues", [ - 'title' => $title, - 'body' => $body, - 'labels' => $labels, - 'assignees' => ['jmiller'], - ]); - $num = $issue['number'] ?? '?'; - $this->log(" ๐Ÿ“‹ Tracking issue #{$num} created in {$repo}", 'INFO'); - } - - // Link the tracking issue to the sync PR so it appears in the PR's Development sidebar - if (is_int($num)) { - try { - $pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNumber}"); - $currentBody = $pr['body'] ?? ''; - $closeRef = "Linked to #{$num}"; - if (!str_contains($currentBody, $closeRef)) { - $this->api->patch("/repos/{$org}/{$repo}/pulls/{$prNumber}", [ - 'body' => $closeRef . "\n\n" . $currentBody, - ]); - } - } catch (\Exception $le) { -/* non-fatal */ - } - } - - return is_int($num) ? $num : null; - } catch (\Exception $e) { - $this->log(" โš ๏ธ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN'); - return null; - } - } - - /** - * Create a tracking issue in mokocli for this sync run. - */ - private function createSyncIssue(string $org, array $results): void - { - if ($this->dryRun) { - return; - } - - $now = gmdate('Y-m-d H:i:s') . ' UTC'; - $total = $results['total']; - $success = $results['success']; - $skipped = $results['skipped']; - $failed = $results['failed']; - $duration = round($results['duration'] ?? 0, 1); - $force = isset($this->options['force']) ? ' *(--force)*' : ''; - $prs = $results['prs'] ?? []; - $issues = $results['issues'] ?? []; - - // Stable title โ€” no timestamp so repeated runs update a single issue - $title = "sync: mokocli v" . self::VERSION_MINOR . " bulk sync report"; - - $protection = $results['protection'] ?? []; - $hasProtect = !empty($protection); - $healthData = $results['health'] ?? []; - $hasHealth = !empty($healthData); - - // Build repo table - $rows = []; - foreach ($results['repositories'] as $repo => $status) { - $icon = match (true) { - $status === 'success' => 'โœ…', - str_starts_with($status, 'skipped') => 'โŠ˜', - str_starts_with($status, 'failed') => 'โŒ', - default => 'โš ๏ธ', - }; - $prLink = isset($prs[$repo]) - ? "[#{$prs[$repo]}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prs[$repo]) . ")" - : 'โ€”'; - $issueLink = isset($issues[$repo]) - ? "[#{$issues[$repo]}](" . $this->adapter->getIssueWebUrl($org, $repo, $issues[$repo]) . ")" - : 'โ€”'; - $row = "| `{$repo}` | {$icon} {$status} | {$prLink} | {$issueLink} |"; - if ($hasHealth) { - $h = $healthData[$repo] ?? null; - if ($h) { - $healthIcon = match ($h['level']) { - 'excellent' => '๐ŸŸข', - 'good' => '๐ŸŸก', - 'fair' => '๐ŸŸ ', - default => '๐Ÿ”ด', - }; - $row .= " {$healthIcon} {$h['score']}/{$h['max']} |"; - } else { - $row .= ' โ€” |'; - } - } - $rows[] = $row; - } - $table = implode("\n", $rows); - - $header = $hasHealth - ? "| Repository | Status | PR | Issue | Health |" - : "| Repository | Status | PR | Issue |"; - $separator = $hasHealth - ? "|---|---|---|---|---|" - : "|---|---|---|---|"; - - $body = <<api->get("/repos/{$org}/mokocli/issues", [ - 'labels' => 'sync-report', - 'state' => 'all', - 'per_page' => 1, - 'sort' => 'created', - 'direction' => 'desc', - ]); - - $labelNames = ['sync-report', 'mokocli', 'type: chore', 'automation']; - $labels = $this->resolveLabelIds($org, 'mokocli', $labelNames); - $existing = array_values($existing); - - if (!empty($existing) && isset($existing[0]['number'])) { - $issueNumber = $existing[0]['number']; - $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; - if (($existing[0]['state'] ?? 'open') === 'closed') { - $patch['state'] = 'open'; - } - $this->api->patch("/repos/{$org}/mokocli/issues/{$issueNumber}", $patch); - try { - $this->api->post("/repos/{$org}/mokocli/issues/{$issueNumber}/labels", ['labels' => $labels]); - } catch (\Exception $le) { -/* non-fatal */ - } - $this->log("๐Ÿ“‹ Sync report issue updated: {$org}/mokocli#{$issueNumber}", 'INFO'); - } else { - $issue = $this->api->post("/repos/{$org}/mokocli/issues", [ - 'title' => $title, - 'body' => $body, - 'labels' => $labels, - 'assignees' => ['jmiller'], - ]); - $issueNumber = $issue['number'] ?? '?'; - $this->log("๐Ÿ“‹ Sync report issue created: {$org}/mokocli#{$issueNumber}", 'INFO'); - } - } catch (\Exception $e) { - $this->log("โš ๏ธ Failed to create/update sync report issue: " . $e->getMessage(), 'WARN'); - } - } - - /** - * Create or update a failure issue in mokocli when repos fail to sync. - * Uses the 'sync-failure' label so it is distinct from the run-report issue. - * Reopens a closed issue rather than creating a duplicate. - */ - private function createFailureIssue(string $org, array $results): void - { - if ($this->dryRun) { - return; - } - - $now = gmdate('Y-m-d H:i:s') . ' UTC'; - $failed = $results['failed']; - $version = self::VERSION; - - $failedRepos = array_keys(array_filter( - $results['repositories'] ?? [], - fn($s) => $s === 'failed' - )); - - $repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos)); - - $title = "fix: bulk_sync failed for {$failed} repo(s) โ€” action required"; - - $body = <<` to see the specific error. - 2. Fix the underlying issue (API token, rate limit, branch protection, etc.). - 3. Re-run: `php automation/bulk_sync.php --org={$org} --repos= --force --yes` - 4. Close this issue once all repos are synced successfully. - - --- - *Auto-created by `bulk_sync.php` โ€” close once resolved.* - MD; - - $body = preg_replace('/^ /m', '', $body); - - try { - $existing = $this->api->get("/repos/{$org}/mokocli/issues", [ - 'labels' => 'sync-failure', - 'state' => 'all', - 'per_page' => 1, - 'sort' => 'created', - 'direction' => 'desc', - ]); - $existing = array_values($existing); - - if (!empty($existing) && isset($existing[0]['number'])) { - $num = $existing[0]['number']; - $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; - if (($existing[0]['state'] ?? 'open') === 'closed') { - $patch['state'] = 'open'; - } - $this->api->patch("/repos/{$org}/mokocli/issues/{$num}", $patch); - $this->log("๐Ÿšจ Failure issue #{$num} updated: {$org}/mokocli#{$num}", 'WARN'); - } else { - $issue = $this->api->post("/repos/{$org}/mokocli/issues", [ - 'title' => $title, - 'body' => $body, - 'labels' => $this->resolveLabelIds($org, 'mokocli', ['sync-failure']), - 'assignees' => ['jmiller'], - ]); - $num = $issue['number'] ?? '?'; - $this->log("๐Ÿšจ Failure issue created: {$org}/mokocli#{$num}", 'WARN'); - } - } catch (\Exception $e) { - $this->log("โš ๏ธ Could not create/update failure issue: " . $e->getMessage(), 'WARN'); - } - } -} - -// Execute if run directly -if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) { - $app = new BulkSync(); - exit($app->execute()); -} diff --git a/automation/bulk_workflow_trigger.sh b/automation/bulk_workflow_trigger.sh deleted file mode 100644 index c63032d..0000000 --- a/automation/bulk_workflow_trigger.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env bash -# Copyright (C) 2026 Moko Consulting -# SPDX-License-Identifier: GPL-3.0-or-later -# BRIEF: Trigger a workflow across all client-waas repos in a Gitea org - -set -euo pipefail - -# --------------------------------------------------------------------------- -# Usage -# --------------------------------------------------------------------------- -usage() { - cat < -# -# SPDX-License-Identifier: GPL-3.0-or-later -# -# FILE INFORMATION -# DEFGROUP: Automation.CI -# INGROUP: mokocli.Automation -# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli -# PATH: /automation/ci-issue-reporter.sh -# VERSION: 09.23.00 -# BRIEF: Creates or updates a Gitea issue when a CI gate fails. -# Deduplicates by searching open issues with the "ci-auto" label -# whose title matches the gate. If a matching issue exists, a comment -# is appended instead of opening a duplicate. -# ============================================================================ - -set -euo pipefail - -# โ”€โ”€ Defaults โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}" -GITEA_TOKEN="${GITEA_TOKEN:-}" -REPO="${GITHUB_REPOSITORY:-}" -RUN_URL="${GITHUB_SERVER_URL:-${GITEA_URL}}/${REPO}/actions/runs/${GITHUB_RUN_ID:-0}" -LABEL_NAME="ci-auto" -LABEL_COLOR="#e11d48" - -GATE="" -DETAILS="" -SEVERITY="error" -WORKFLOW="" - -# โ”€โ”€ Parse arguments โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -usage() { - cat </dev/null || echo "000") - - if [[ "$exists" == "200" ]]; then - # Check if label already exists - local found - found=$(curl -sf \ - -H "Authorization: token ${GITEA_TOKEN}" \ - "${API}/labels" 2>/dev/null \ - | grep -o "\"name\":\"${LABEL_NAME}\"" || true) - - if [[ -z "$found" ]]; then - curl -sf -X POST \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Content-Type: application/json" \ - "${API}/labels" \ - -d "{\"name\":\"${LABEL_NAME}\",\"color\":\"${LABEL_COLOR}\",\"description\":\"Auto-created by CI issue reporter\"}" \ - > /dev/null 2>&1 || true - fi - fi -} - -# โ”€โ”€ Search for existing open issue โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -find_existing_issue() { - # URL-encode the gate name for the query - local query - query=$(printf '%s' "[CI] ${GATE}" | sed 's/ /%20/g; s/\[/%5B/g; s/\]/%5D/g') - - local response - response=$(curl -sf \ - -H "Authorization: token ${GITEA_TOKEN}" \ - "${API}/issues?type=issues&state=open&labels=${LABEL_NAME}&q=${query}&limit=5" \ - 2>/dev/null || echo "[]") - - # Extract the first matching issue number - echo "$response" \ - | grep -oP '"number":\s*\K[0-9]+' \ - | head -1 -} - -# โ”€โ”€ Build issue body โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -build_body() { - local severity_badge - if [[ "$SEVERITY" == "error" ]]; then - severity_badge="**Severity:** Error" - else - severity_badge="**Severity:** Warning" - fi - - cat </dev/null) - - HTTP=$(curl -sf -o /dev/null -w '%{http_code}' -X POST \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Content-Type: application/json" \ - "${API}/issues/${EXISTING}/comments" \ - -d "${COMMENT_JSON}" 2>/dev/null || echo "000") - - if [[ "$HTTP" == "201" ]]; then - echo "Commented on existing issue #${EXISTING}" - else - echo "WARNING: Failed to comment on issue #${EXISTING} (HTTP ${HTTP})" - fi -else - # Create new issue - ISSUE_BODY=$(build_body) - ISSUE_JSON=$(python3 -c " -import sys, json -body = sys.stdin.read() -print(json.dumps({ - 'title': sys.argv[1], - 'body': body, - 'labels': [] -}))" "$TITLE" <<< "$ISSUE_BODY" 2>/dev/null) - - # Create the issue - RESPONSE=$(curl -sf -X POST \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Content-Type: application/json" \ - "${API}/issues" \ - -d "${ISSUE_JSON}" 2>/dev/null || echo "{}") - - ISSUE_NUM=$(echo "$RESPONSE" | grep -oP '"number":\s*\K[0-9]+' | head -1) - - if [[ -n "$ISSUE_NUM" ]]; then - # Apply label (separate call โ€” more reliable across Gitea versions) - LABEL_ID=$(curl -sf \ - -H "Authorization: token ${GITEA_TOKEN}" \ - "${API}/labels" 2>/dev/null \ - | grep -oP "\"id\":\s*\K[0-9]+(?=[^}]*\"name\":\s*\"${LABEL_NAME}\")" \ - | head -1 || true) - - if [[ -n "$LABEL_ID" ]]; then - curl -sf -X POST \ - -H "Authorization: token ${GITEA_TOKEN}" \ - -H "Content-Type: application/json" \ - "${API}/issues/${ISSUE_NUM}/labels" \ - -d "{\"labels\":[${LABEL_ID}]}" \ - > /dev/null 2>&1 || true - fi - - echo "Created issue #${ISSUE_NUM}: ${TITLE}" - else - echo "WARNING: Failed to create issue" - echo "Response: ${RESPONSE}" - fi -fi diff --git a/automation/enforce_tags.sh b/automation/enforce_tags.sh deleted file mode 100755 index 9d4f329..0000000 --- a/automation/enforce_tags.sh +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# enforce_tags.sh โ€” Ensure all repos have the 5 standard release channel tags -# -# Standard tags: development, alpha, beta, release-candidate, stable -# Also removes non-standard tags (keeps vXX production tags) -# -# Usage: -# GA_TOKEN=xxx ./enforce_tags.sh [--dry-run] [--repos repo1,repo2] -# -# Called by: bulk-repo-sync.yml, infrastructure-tests/mirror-check.yml -# ============================================================================= -set -euo pipefail - -GITEA_URL="${GITEA_URL:-https://git.mokoconsulting.tech}" -ORG="${GITEA_ORG:-MokoConsulting}" -TOKEN="${GA_TOKEN:?GA_TOKEN required}" -DRY_RUN=false -FILTER_REPOS="" - -STANDARD_TAGS=("development" "alpha" "beta" "release-candidate" "stable") - -while [[ $# -gt 0 ]]; do - case "$1" in - --dry-run) DRY_RUN=true; shift ;; - --repos) FILTER_REPOS="$2"; shift 2 ;; - *) shift ;; - esac -done - -api() { - local method="$1" path="$2" data="${3:-}" - local args=(-sf -H "Authorization: token $TOKEN" -H "Content-Type: application/json" -X "$method") - [[ -n "$data" ]] && args+=(-d "$data") - curl "${args[@]}" "$GITEA_URL/api/v1$path" 2>/dev/null -} - -# Get repos -REPOS="" -for page in 1 2 3; do - BATCH=$(api GET "/orgs/$ORG/repos?limit=50&page=$page" | python3 -c " -import sys,json -for r in json.load(sys.stdin): - if not r.get(empty) and not r.get(archived): - print(r[name]) -" 2>/dev/null) - [[ -z "$BATCH" ]] && break - REPOS="$REPOS $BATCH" -done - -# Filter if specified -if [[ -n "$FILTER_REPOS" ]]; then - FILTERED="" - IFS=, read -ra FILTER_ARR <<< "$FILTER_REPOS" - for repo in $REPOS; do - for f in "${FILTER_ARR[@]}"; do - [[ "$repo" == "$f" ]] && FILTERED="$FILTERED $repo" - done - done - REPOS="$FILTERED" -fi - -TOTAL=$(echo $REPOS | wc -w) -ADDED=0 -DELETED=0 -ERRORS=0 - -echo "Enforcing tags on $TOTAL repos (dry_run=$DRY_RUN)" - -for repo in $REPOS; do - TAGS=$(api GET "/repos/$ORG/$repo/tags?limit=50" | python3 -c "import sys,json; print( .join(t[name] for t in json.load(sys.stdin)))" 2>/dev/null) - MAIN_SHA=$(api GET "/repos/$ORG/$repo/branches/main" | python3 -c "import sys,json; print(json.load(sys.stdin)[commit][id])" 2>/dev/null) - [[ -z "$MAIN_SHA" ]] && continue - - # Add missing standard tags - for st in "${STANDARD_TAGS[@]}"; do - if ! echo " $TAGS " | grep -q " $st "; then - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY] ADD $repo: $st" - else - STATUS=$(api POST "/repos/$ORG/$repo/tags" "{\"tag_name\":\"$st\",\"target\":\"$MAIN_SHA\"}" | python3 -c "import sys,json; print(ok)" 2>/dev/null || echo "err") - [[ "$STATUS" == "ok" ]] && ADDED=$((ADDED + 1)) || ERRORS=$((ERRORS + 1)) - fi - fi - done - - # Remove non-standard tags - for t in $TAGS; do - IS_STD=false - for st in "${STANDARD_TAGS[@]}"; do [[ "$t" == "$st" ]] && IS_STD=true; done - # Keep vXX production tags - if [[ "$t" =~ ^v[0-9]{1,3}$ ]]; then IS_STD=true; fi - - if [[ "$IS_STD" == "false" ]]; then - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY] DEL $repo: $t" - else - # Delete release first if exists - api DELETE "/repos/$ORG/$repo/releases/tags/$t" > /dev/null 2>&1 || true - api DELETE "/repos/$ORG/$repo/tags/$t" > /dev/null 2>&1 - DELETED=$((DELETED + 1)) - echo " DEL $repo: $t" - fi - fi - done -done - -echo "Done: $ADDED added, $DELETED deleted, $ERRORS errors (dry_run=$DRY_RUN)" diff --git a/automation/enrich_manifest_xml.php b/automation/enrich_manifest_xml.php deleted file mode 100644 index 0f82a7b..0000000 --- a/automation/enrich_manifest_xml.php +++ /dev/null @@ -1,481 +0,0 @@ -#!/usr/bin/env php - - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoPlatform.Automation - * INGROUP: MokoPlatform - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli - * PATH: /automation/enrich_manifest_xml.php - * BRIEF: Enrich XML manifests with repo-specific build and deploy details - * - * Note: This script uses proc_open for shell commands. All arguments are escaped - * via escapeshellarg(). No user-supplied input reaches the shell unescaped. - */ - -declare(strict_types=1); - -require_once __DIR__ . '/../vendor/autoload.php'; -require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; - -use MokoCli\CliFramework; -use MokoCli\ManifestParser; - -class EnrichManifestXmlCli extends CliFramework -{ - protected function configure(): void - { - $this->setDescription('Enrich XML manifests with repo-specific build and deploy details'); - $this->addArgument('--repo', 'Filter to a single repo name', ''); - $this->addArgument('--skip', 'Comma-separated list of repos to skip', ''); - } - - protected function run(): int - { - $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); - $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; - $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; - - $repoFilter = $this->getArgument('--repo') ?: null; - $skipStr = $this->getArgument('--skip'); - $skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : []; - - $parser = new ManifestParser(); - $tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid(); - - echo "=== mokocli XML Manifest Enrichment ===\n"; - echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n"; - if (!empty($skipRepos)) { - echo "Skipping: " . implode(', ', $skipRepos) . "\n"; - } - echo "\n"; - - if (empty($token)) { - $this->log('ERROR', 'GA_TOKEN required'); - return 1; - } - - $repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token); - echo "Found " . count($repos) . " repositories\n\n"; - - $stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0]; - - foreach ($repos as $repo) { - $name = $repo['name']; - if ($repoFilter && $name !== $repoFilter) { - continue; - } - if (in_array($name, $skipRepos, true)) { - echo " {$name} ... SKIP (excluded)\n"; - $stats['skipped']++; - continue; - } - if ($repo['archived'] ?? false) { - $stats['skipped']++; - continue; - } - - $defaultBranch = $repo['default_branch'] ?? 'main'; - $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; - $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); - - echo " {$name} ... "; - - $workDir = "{$tmpBase}/{$name}"; - @mkdir($workDir, 0755, true); - [$ret] = $this->safeExec( - 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) - . ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) - ); - if ($ret !== 0) { - echo "FAIL (clone)\n"; - $stats['failed']++; - continue; - } - - $manifestPath = "{$workDir}/.mokogitea/manifest.xml"; - if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), 'rmTree($workDir); - continue; - } - - $existingXml = file_get_contents($manifestPath); - $platform = $parser->extractPlatform($existingXml) ?? 'default-repository'; - $enrichment = $this->inspectRepo($workDir, $platform); - - if (!isset($enrichment['build'])) { - $enrichment['build'] = []; - } - $enrichment['build']['language'] = $enrichment['build']['language'] - ?? $repo['language'] - ?? ManifestParser::platformLanguage($platform); - $enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? ManifestParser::platformPackageType($platform); - - $enrichedXml = $this->enrichManifestXml($existingXml, $enrichment); - $dc = count($enrichment['deploy'] ?? []); - $sc = count($enrichment['scripts'] ?? []); - $details = "deploy={$dc} scripts={$sc}"; - - if ($this->dryRun) { - echo "WOULD ENRICH [{$details}]\n"; - $stats['enriched']++; - $this->rmTree($workDir); - continue; - } - - file_put_contents($manifestPath, $enrichedXml); - $this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); - $this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); - $this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml'); - - [$cr, $co] = $this->gitCmd($workDir, 'commit', '-m', "chore: enrich manifest.xml with build/deploy/scripts\n\nAuto-detected: {$details}"); - if ($cr !== 0) { - echo "SKIP (no diff)\n"; - $stats['skipped']++; - $this->rmTree($workDir); - continue; - } - - [$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch); - if ($pr !== 0) { - echo "FAIL (push)\n"; - $stats['failed']++; - } else { - echo "ENRICHED [{$details}]\n"; - $stats['enriched']++; - } - - $this->rmTree($workDir); - } - - @rmdir($tmpBase); - echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n"; - - return 0; - } - - private function inspectRepo(string $workDir, string $platform): array - { - $enrichment = []; - $build = []; - - // Detect entry point - if (is_dir("{$workDir}/src")) { - foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) { - $c = file_get_contents($xf); - if (str_contains($c, ' $pd, 'version' => $composer['require'][$pd], 'type' => 'platform']; - } - } - if (isset($composer['require']['mokoconsulting-tech/enterprise'])) { - $deps[] = [ - 'name' => 'mokoconsulting-tech/enterprise', - 'version' => $composer['require']['mokoconsulting-tech/enterprise'], - 'type' => 'composer', - ]; - } - if (!empty($deps)) { - $build['dependencies'] = $deps; - } - } - - // Artifact from Makefile - if (file_exists("{$workDir}/Makefile")) { - $mk = file_get_contents("{$workDir}/Makefile"); - if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) { - $build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]]; - } - } - - if (!empty($build)) { - $enrichment['build'] = $build; - } - - // Deploy targets from workflows - $targets = []; - $wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows"; - if (is_dir($wfDir)) { - foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) { - $wf = "{$wfDir}/{$dn}.yml"; - if (!file_exists($wf)) { - continue; - } - $wc = file_get_contents($wf); - $t = ['name' => str_replace('deploy-', '', $dn)]; - if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) { - $t['method'] = 'sftp'; - } elseif (str_contains($wc, 'rsync')) { - $t['method'] = 'rsync'; - } - if (str_contains($wc, 'src/')) { - $t['src_dir'] = 'src/'; - } - if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) { - $t['branch'] = $m[1]; - } - $targets[] = $t; - } - } - if (!empty($targets)) { - $enrichment['deploy'] = $targets; - } - - // Scripts from Makefile + composer - $scripts = []; - if (file_exists("{$workDir}/Makefile")) { - $mk = file_get_contents("{$workDir}/Makefile"); - $known = [ - 'build' => 'build', 'test' => 'test', 'lint' => 'lint', - 'clean' => 'build', 'package' => 'build', - 'validate' => 'validate', 'release' => 'release', - ]; - if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) { - foreach ($matches[1] as $tgt) { - $tl = strtolower($tgt); - if (isset($known[$tl])) { - $scripts[] = [ - 'name' => $tl, 'phase' => $known[$tl], - 'command' => "make {$tgt}", - 'desc' => ucfirst($tl) . ' via make', - 'runner' => 'make', - ]; - } - } - } - } - if (file_exists("{$workDir}/composer.json")) { - $composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: []; - $km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate']; - foreach ($composer['scripts'] ?? [] as $sn => $cmd) { - $sl = strtolower($sn); - foreach ($km as $match => $phase) { - if (str_contains($sl, $match)) { - $exists = false; - foreach ($scripts as $s) { - if ($s['name'] === $sl) { - $exists = true; - break; - } - } - if (!$exists) { - $scripts[] = [ - 'name' => $sn, 'phase' => $phase, - 'command' => "composer run {$sn}", - 'desc' => is_string($cmd) ? $cmd : "Run {$sn}", - 'runner' => 'composer', - ]; - } - break; - } - } - } - } - if (!empty($scripts)) { - $enrichment['scripts'] = $scripts; - } - - return $enrichment; - } - - private function enrichManifestXml(string $xml, array $enrichment): string - { - $dom = new \DOMDocument('1.0', 'UTF-8'); - $dom->preserveWhiteSpace = false; - $dom->formatOutput = true; - if (!$dom->loadXML($xml)) { - return $xml; - } - - $ns = ManifestParser::NAMESPACE_URI; - $root = $dom->documentElement; - - foreach (['build', 'deploy', 'scripts'] as $tag) { - $toRemove = []; - $existing = $root->getElementsByTagNameNS($ns, $tag); - for ($i = 0; $i < $existing->length; $i++) { - $toRemove[] = $existing->item($i); - } - foreach ($toRemove as $node) { - $root->removeChild($node); - } - } - - if (!empty($enrichment['build'])) { - $buildEl = $dom->createElementNS($ns, 'build'); - $b = $enrichment['build']; - foreach (['language', 'runtime'] as $f) { - if (isset($b[$f])) { - $buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1))); - } - } - if (isset($b['package_type'])) { - $buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1))); - } - if (isset($b['entry_point'])) { - $buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1))); - } - if (isset($b['artifact'])) { - $art = $dom->createElementNS($ns, 'artifact'); - foreach (['format','path','filename'] as $af) { - if (isset($b['artifact'][$af])) { - $art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1))); - } - } - $buildEl->appendChild($art); - } - if (isset($b['dependencies'])) { - $deps = $dom->createElementNS($ns, 'dependencies'); - foreach ($b['dependencies'] as $d) { - $req = $dom->createElementNS($ns, 'requires', ''); - $req->setAttribute('name', $d['name']); - if (isset($d['version'])) { - $req->setAttribute('version', $d['version']); - } - if (isset($d['type'])) { - $req->setAttribute('type', $d['type']); - } - $deps->appendChild($req); - } - $buildEl->appendChild($deps); - } - $root->appendChild($buildEl); - } - - if (!empty($enrichment['deploy'])) { - $deploy = $dom->createElementNS($ns, 'deploy'); - foreach ($enrichment['deploy'] as $t) { - $target = $dom->createElementNS($ns, 'target'); - $target->setAttribute('name', $t['name']); - $target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}')); - $target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}')); - if (isset($t['method'])) { - $target->appendChild($dom->createElementNS($ns, 'method', $t['method'])); - } - if (isset($t['branch'])) { - $target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1))); - } - if (isset($t['src_dir'])) { - $target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1))); - } - $deploy->appendChild($target); - } - $root->appendChild($deploy); - } - - if (!empty($enrichment['scripts'])) { - $scriptsEl = $dom->createElementNS($ns, 'scripts'); - foreach ($enrichment['scripts'] as $s) { - $script = $dom->createElementNS($ns, 'script'); - $script->setAttribute('name', $s['name']); - if (isset($s['phase'])) { - $script->setAttribute('phase', $s['phase']); - } - $script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1))); - if (isset($s['desc'])) { - $script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1))); - } - if (isset($s['runner'])) { - $script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1))); - } - $scriptsEl->appendChild($script); - } - $root->appendChild($scriptsEl); - } - - return $dom->saveXML(); - } - - /** @return array{int, string} */ - private function safeExec(string $command, string $cwd = '.'): array - { - $proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd); - if (!is_resource($proc)) { - return [1, "proc_open failed"]; - } - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - return [proc_close($proc), trim($stdout . "\n" . $stderr)]; - } - - private function rmTree(string $dir): void - { - if (!is_dir($dir)) { - return; - } - $it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); - $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); - foreach ($files as $file) { - if ($file->isDir()) { - @rmdir($file->getPathname()); - } else { - @chmod($file->getPathname(), 0777); - @unlink($file->getPathname()); - } - } - @rmdir($dir); - } - - /** @return array{int, string} */ - private function gitCmd(string $workDir, string ...$args): array - { - $cmd = 'git'; - foreach ($args as $a) { - $cmd .= ' ' . escapeshellarg($a); - } - return $this->safeExec($cmd, $workDir); - } - - private function fetchRepos(string $url, string $org, string $token): array - { - $repos = []; - $page = 1; - do { - $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); - curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]); - $body = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - if ($code !== 200) { - break; - } - $batch = json_decode($body, true); - if (empty($batch)) { - break; - } - $repos = array_merge($repos, $batch); - $page++; - } while (count($batch) >= 50); - return $repos; - } -} - -$app = new EnrichManifestXmlCli(); -exit($app->execute()); diff --git a/automation/enrich_mokostandards_xml.php b/automation/enrich_mokostandards_xml.php deleted file mode 100644 index 686e6b3..0000000 --- a/automation/enrich_mokostandards_xml.php +++ /dev/null @@ -1,484 +0,0 @@ -#!/usr/bin/env php - - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoPlatform.Automation - * INGROUP: MokoPlatform - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli - * PATH: /automation/enrich_mokostandards_xml.php - * BRIEF: Enrich XML manifests with repo-specific build and deploy details - * - * Note: This script uses proc_open for shell commands. All arguments are escaped - * via escapeshellarg(). No user-supplied input reaches the shell unescaped. - */ - -declare(strict_types=1); - -require_once __DIR__ . '/../vendor/autoload.php'; -require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; - -use MokoCli\CliFramework; -use MokoCli\ManifestParser; - -class EnrichMokostandardsXmlCli extends CliFramework -{ - protected function configure(): void - { - $this->setDescription('Enrich XML manifests with repo-specific build and deploy details'); - $this->addArgument('--repo', 'Filter to a single repo name', ''); - $this->addArgument('--skip', 'Comma-separated list of repos to skip', ''); - } - - protected function run(): int - { - $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); - $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; - $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; - - $repoFilter = $this->getArgument('--repo') ?: null; - $skipStr = $this->getArgument('--skip'); - $skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : []; - - $parser = new ManifestParser(); - $tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid(); - - echo "=== mokocli XML Manifest Enrichment ===\n"; - echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n"; - if (!empty($skipRepos)) { - echo "Skipping: " . implode(', ', $skipRepos) . "\n"; - } - echo "\n"; - - if (empty($token)) { - $this->log('ERROR', 'GA_TOKEN required'); - return 1; - } - - $repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token); - echo "Found " . count($repos) . " repositories\n\n"; - - $stats = ['enriched' => 0, 'skipped' => 0, 'failed' => 0]; - - foreach ($repos as $repo) { - $name = $repo['name']; - if ($repoFilter && $name !== $repoFilter) { - continue; - } - if (in_array($name, $skipRepos, true)) { - echo " {$name} ... SKIP (excluded)\n"; - $stats['skipped']++; - continue; - } - if ($repo['archived'] ?? false) { - $stats['skipped']++; - continue; - } - - $defaultBranch = $repo['default_branch'] ?? 'main'; - $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; - $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); - - echo " {$name} ... "; - - $workDir = "{$tmpBase}/{$name}"; - @mkdir($workDir, 0755, true); - [$ret] = $this->safeExec( - 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) - . ' ' . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) - ); - if ($ret !== 0) { - echo "FAIL (clone)\n"; - $stats['failed']++; - continue; - } - - $manifestPath = "{$workDir}/.mokogitea/manifest.xml"; - if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), 'rmTree($workDir); - continue; - } - - $existingXml = file_get_contents($manifestPath); - $platform = $parser->extractPlatform($existingXml) ?? 'default-repository'; - $enrichment = $this->inspectRepo($workDir, $platform); - - if (!isset($enrichment['build'])) { - $enrichment['build'] = []; - } - $enrichment['build']['language'] = $enrichment['build']['language'] - ?? $repo['language'] - ?? ManifestParser::platformLanguage($platform); - $enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? ManifestParser::platformPackageType($platform); - - $enrichedXml = $this->enrichManifestXml($existingXml, $enrichment); - $dc = count($enrichment['deploy'] ?? []); - $sc = count($enrichment['scripts'] ?? []); - $details = "deploy={$dc} scripts={$sc}"; - - if ($this->dryRun) { - echo "WOULD ENRICH [{$details}]\n"; - $stats['enriched']++; - $this->rmTree($workDir); - continue; - } - - file_put_contents($manifestPath, $enrichedXml); - $this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); - $this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); - $this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml'); - - $commitMsg = "chore: enrich .mokostandards" - . " with build/deploy/scripts\n\n" - . "Auto-detected: {$details}"; - [$cr, $co] = $this->gitCmd( - $workDir, - 'commit', - '-m', - $commitMsg - ); - if ($cr !== 0) { - echo "SKIP (no diff)\n"; - $stats['skipped']++; - $this->rmTree($workDir); - continue; - } - - [$pr] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch); - if ($pr !== 0) { - echo "FAIL (push)\n"; - $stats['failed']++; - } else { - echo "ENRICHED [{$details}]\n"; - $stats['enriched']++; - } - - $this->rmTree($workDir); - } - - @rmdir($tmpBase); - echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n"; - - return 0; - } - - private function inspectRepo(string $workDir, string $platform): array - { - $enrichment = []; - $build = []; - - if (is_dir("{$workDir}/src")) { - foreach (glob("{$workDir}/src/*.xml") ?: [] as $xf) { - $c = file_get_contents($xf); - if (str_contains($c, ' $pd, 'version' => $composer['require'][$pd], 'type' => 'platform']; - } - } - if (isset($composer['require']['mokoconsulting-tech/enterprise'])) { - $deps[] = [ - 'name' => 'mokoconsulting-tech/enterprise', - 'version' => $composer['require']['mokoconsulting-tech/enterprise'], - 'type' => 'composer', - ]; - } - if (!empty($deps)) { - $build['dependencies'] = $deps; - } - } - - if (file_exists("{$workDir}/Makefile")) { - $mk = file_get_contents("{$workDir}/Makefile"); - if (preg_match('/\bdist\/(\S+\.zip)\b/', $mk, $m)) { - $build['artifact'] = ['format' => 'zip', 'path' => 'dist/', 'filename' => $m[1]]; - } - } - - if (!empty($build)) { - $enrichment['build'] = $build; - } - - $targets = []; - $wfDir = is_dir("{$workDir}/.gitea/workflows") ? "{$workDir}/.gitea/workflows" : "{$workDir}/.github/workflows"; - if (is_dir($wfDir)) { - foreach (['deploy-dev', 'deploy-demo', 'deploy-rs'] as $dn) { - $wf = "{$wfDir}/{$dn}.yml"; - if (!file_exists($wf)) { - continue; - } - $wc = file_get_contents($wf); - $t = ['name' => str_replace('deploy-', '', $dn)]; - if (str_contains($wc, 'sftp') || str_contains($wc, 'SFTP')) { - $t['method'] = 'sftp'; - } elseif (str_contains($wc, 'rsync')) { - $t['method'] = 'rsync'; - } - if (str_contains($wc, 'src/')) { - $t['src_dir'] = 'src/'; - } - if (preg_match('/branches:\s*\n\s*-\s*["\']?([^"\'}\s]+)/', $wc, $m)) { - $t['branch'] = $m[1]; - } - $targets[] = $t; - } - } - if (!empty($targets)) { - $enrichment['deploy'] = $targets; - } - - $scripts = []; - if (file_exists("{$workDir}/Makefile")) { - $mk = file_get_contents("{$workDir}/Makefile"); - $known = [ - 'build' => 'build', 'test' => 'test', 'lint' => 'lint', - 'clean' => 'build', 'package' => 'build', - 'validate' => 'validate', 'release' => 'release', - ]; - if (preg_match_all('/^([a-zA-Z_-]+)\s*:/m', $mk, $matches)) { - foreach ($matches[1] as $tgt) { - $tl = strtolower($tgt); - if (isset($known[$tl])) { - $scripts[] = [ - 'name' => $tl, 'phase' => $known[$tl], - 'command' => "make {$tgt}", - 'desc' => ucfirst($tl) . ' via make', - 'runner' => 'make', - ]; - } - } - } - } - if (file_exists("{$workDir}/composer.json")) { - $composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: []; - $km = ['test' => 'test','lint' => 'lint','cs' => 'lint','phpcs' => 'lint','phpstan' => 'lint','validate' => 'validate']; - foreach ($composer['scripts'] ?? [] as $sn => $cmd) { - $sl = strtolower($sn); - foreach ($km as $match => $phase) { - if (str_contains($sl, $match)) { - $exists = false; - foreach ($scripts as $s) { - if ($s['name'] === $sl) { - $exists = true; - break; - } - } - if (!$exists) { - $scripts[] = [ - 'name' => $sn, 'phase' => $phase, - 'command' => "composer run {$sn}", - 'desc' => is_string($cmd) ? $cmd : "Run {$sn}", - 'runner' => 'composer', - ]; - } - break; - } - } - } - } - if (!empty($scripts)) { - $enrichment['scripts'] = $scripts; - } - - return $enrichment; - } - - private function enrichManifestXml(string $xml, array $enrichment): string - { - $dom = new \DOMDocument('1.0', 'UTF-8'); - $dom->preserveWhiteSpace = false; - $dom->formatOutput = true; - if (!$dom->loadXML($xml)) { - return $xml; - } - - $ns = ManifestParser::NAMESPACE_URI; - $root = $dom->documentElement; - - foreach (['build', 'deploy', 'scripts'] as $tag) { - $toRemove = []; - $existing = $root->getElementsByTagNameNS($ns, $tag); - for ($i = 0; $i < $existing->length; $i++) { - $toRemove[] = $existing->item($i); - } - foreach ($toRemove as $node) { - $root->removeChild($node); - } - } - - if (!empty($enrichment['build'])) { - $buildEl = $dom->createElementNS($ns, 'build'); - $b = $enrichment['build']; - foreach (['language', 'runtime'] as $f) { - if (isset($b[$f])) { - $buildEl->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1))); - } - } - if (isset($b['package_type'])) { - $buildEl->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1))); - } - if (isset($b['entry_point'])) { - $buildEl->appendChild($dom->createElementNS($ns, 'entry-point', htmlspecialchars($b['entry_point'], ENT_XML1))); - } - if (isset($b['artifact'])) { - $art = $dom->createElementNS($ns, 'artifact'); - foreach (['format','path','filename'] as $af) { - if (isset($b['artifact'][$af])) { - $art->appendChild($dom->createElementNS($ns, $af, htmlspecialchars($b['artifact'][$af], ENT_XML1))); - } - } - $buildEl->appendChild($art); - } - if (isset($b['dependencies'])) { - $deps = $dom->createElementNS($ns, 'dependencies'); - foreach ($b['dependencies'] as $d) { - $req = $dom->createElementNS($ns, 'requires', ''); - $req->setAttribute('name', $d['name']); - if (isset($d['version'])) { - $req->setAttribute('version', $d['version']); - } - if (isset($d['type'])) { - $req->setAttribute('type', $d['type']); - } - $deps->appendChild($req); - } - $buildEl->appendChild($deps); - } - $root->appendChild($buildEl); - } - - if (!empty($enrichment['deploy'])) { - $deploy = $dom->createElementNS($ns, 'deploy'); - foreach ($enrichment['deploy'] as $t) { - $target = $dom->createElementNS($ns, 'target'); - $target->setAttribute('name', $t['name']); - $target->appendChild($dom->createElementNS($ns, 'host', '${{ secrets.' . strtoupper($t['name']) . '_HOST }}')); - $target->appendChild($dom->createElementNS($ns, 'path', '${{ secrets.' . strtoupper($t['name']) . '_PATH }}')); - if (isset($t['method'])) { - $target->appendChild($dom->createElementNS($ns, 'method', $t['method'])); - } - if (isset($t['branch'])) { - $target->appendChild($dom->createElementNS($ns, 'branch', htmlspecialchars($t['branch'], ENT_XML1))); - } - if (isset($t['src_dir'])) { - $target->appendChild($dom->createElementNS($ns, 'src-dir', htmlspecialchars($t['src_dir'], ENT_XML1))); - } - $deploy->appendChild($target); - } - $root->appendChild($deploy); - } - - if (!empty($enrichment['scripts'])) { - $scriptsEl = $dom->createElementNS($ns, 'scripts'); - foreach ($enrichment['scripts'] as $s) { - $script = $dom->createElementNS($ns, 'script'); - $script->setAttribute('name', $s['name']); - if (isset($s['phase'])) { - $script->setAttribute('phase', $s['phase']); - } - $script->appendChild($dom->createElementNS($ns, 'command', htmlspecialchars($s['command'], ENT_XML1))); - if (isset($s['desc'])) { - $script->appendChild($dom->createElementNS($ns, 'description', htmlspecialchars($s['desc'], ENT_XML1))); - } - if (isset($s['runner'])) { - $script->appendChild($dom->createElementNS($ns, 'runner', htmlspecialchars($s['runner'], ENT_XML1))); - } - $scriptsEl->appendChild($script); - } - $root->appendChild($scriptsEl); - } - - return $dom->saveXML(); - } - - /** @return array{int, string} */ - private function safeExec(string $command, string $cwd = '.'): array - { - $proc = proc_open($command, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $cwd); - if (!is_resource($proc)) { - return [1, "proc_open failed"]; - } - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - return [proc_close($proc), trim($stdout . "\n" . $stderr)]; - } - - private function rmTree(string $dir): void - { - if (!is_dir($dir)) { - return; - } - $it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); - $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); - foreach ($files as $file) { - if ($file->isDir()) { - @rmdir($file->getPathname()); - } else { - @chmod($file->getPathname(), 0777); - @unlink($file->getPathname()); - } - } - @rmdir($dir); - } - - /** @return array{int, string} */ - private function gitCmd(string $workDir, string ...$args): array - { - $cmd = 'git'; - foreach ($args as $a) { - $cmd .= ' ' . escapeshellarg($a); - } - return $this->safeExec($cmd, $workDir); - } - - private function fetchRepos(string $url, string $org, string $token): array - { - $repos = []; - $page = 1; - do { - $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); - curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], CURLOPT_TIMEOUT => 30]); - $body = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - if ($code !== 200) { - break; - } - $batch = json_decode($body, true); - if (empty($batch)) { - break; - } - $repos = array_merge($repos, $batch); - $page++; - } while (count($batch) >= 50); - return $repos; - } -} - -$app = new EnrichMokostandardsXmlCli(); -exit($app->execute()); diff --git a/automation/file-distributor-config-example.json b/automation/file-distributor-config-example.json deleted file mode 100644 index 3df259a..0000000 --- a/automation/file-distributor-config-example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "description": "Example configuration file for file-distributor.ps1 v02.00.00", - "SourceFile": "C:\\path\\to\\your\\source\\file.txt", - "RootDirectory": "C:\\path\\to\\root\\directory", - "Depth": 1, - "DryRun": true, - "Overwrite": false, - "ConfirmEach": false, - "IncludeHidden": true, - "LogDirectory": "C:\\path\\to\\logs" -} diff --git a/automation/index.md b/automation/index.md deleted file mode 100644 index f064f33..0000000 --- a/automation/index.md +++ /dev/null @@ -1,32 +0,0 @@ - - -# Docs Index: /api/automation - -## Purpose - -This index provides navigation to documentation within this folder. - -## Documents - -- [README-file-distributor](./README-file-distributor.md) -- [README](./README.md) - -## Metadata - -- **Document Type:** index -- **Auto-generated:** This file is automatically generated by rebuild_indexes.py - -## Revision History - -| Date | Author | Change | Notes | -| ---------- | ------------------ | ----------------- | ------------------------------------------ | -| Auto | rebuild_indexes.py | Automated update | Generated by documentation index automation | diff --git a/automation/migrate_to_gitea.php b/automation/migrate_to_gitea.php deleted file mode 100644 index 323b75b..0000000 --- a/automation/migrate_to_gitea.php +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoPlatform.Automation - * INGROUP: MokoPlatform - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli - * PATH: /automation/migrate_to_gitea.php - * BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance - * - * USAGE - * php automation/migrate_to_gitea.php --dry-run - * php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods - * php automation/migrate_to_gitea.php --exclude mokocli --skip-archived - * php automation/migrate_to_gitea.php --resume - */ - -declare(strict_types=1); - -require_once __DIR__ . '/../vendor/autoload.php'; - -use MokoCli\CheckpointManager; -use MokoCli\CliFramework; -use MokoCli\Config; -use MokoCli\PlatformAdapterFactory; -use MokoCli\GitHubAdapter; -use MokoCli\MokoGiteaAdapter; - -/** - * Gitea Migration Script - * - * Migrates repositories from GitHub to a self-hosted Gitea instance. - * Uses Gitea's built-in migration endpoint for git history, tags, releases, - * issues, and labels. Post-migration applies branch protection, topics, - * and workflow conversion. - */ -class MigrateToGitea extends CliFramework -{ - private ?GitHubAdapter $github = null; - private ?MokoGiteaAdapter $gitea = null; - private ?CheckpointManager $checkpoints = null; - - protected function configure(): void - { - $this->setDescription('Migrate repositories from GitHub to Gitea'); - $this->addArgument('--dry-run', 'Show what would be migrated without making changes', false); - $this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', ''); - $this->addArgument('--exclude', 'Repositories to exclude (space-separated)', ''); - $this->addArgument('--skip-archived', 'Skip archived repositories', false); - $this->addArgument('--resume', 'Resume from last checkpoint', false); - $this->addArgument('--github-token', 'GitHub token override', ''); - $this->addArgument('--gitea-token', 'Gitea token override', ''); - } - - protected function run(): int - { - $dryRun = (bool) $this->getArgument('--dry-run'); - $specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos'))); - $excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude'))); - $skipArchived = (bool) $this->getArgument('--skip-archived'); - $resume = (bool) $this->getArgument('--resume'); - - $config = Config::load(); - - // Override tokens if provided - $ghToken = (string) $this->getArgument('--github-token'); - $giteaToken = (string) $this->getArgument('--gitea-token'); - if ($ghToken !== '') { - $config->set('github.token', $ghToken); - } - if ($giteaToken !== '') { - $config->set('gitea.token', $giteaToken); - } - - // Create both adapters - try { - $adapters = PlatformAdapterFactory::createBoth($config); - $this->github = $adapters['github']; - $this->gitea = $adapters['gitea']; - } catch (\RuntimeException $e) { - $this->log('ERROR', $e->getMessage()); - return 1; - } - - $this->checkpoints = new CheckpointManager('.checkpoints/migration'); - $org = $config->getString('github.organization', 'MokoConsulting'); - $giteaOrg = $config->getString('gitea.organization', 'MokoConsulting'); - - echo "=== Gitea Migration Tool ===\n"; - echo "Source: GitHub ({$org})\n"; - echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n"; - echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n"; - - // โ”€โ”€ Phase 1: Discovery โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - $this->section('Phase 1: Discovery'); - - $ghRepos = $this->github->listOrgRepos($org, $skipArchived); - echo "Found " . count($ghRepos) . " repositories on GitHub\n"; - - // Filter repos - if (!empty($specificRepos)) { - $ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true)); - } - if (!empty($excludeRepos)) { - $ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true)); - } - - // Check which already exist on Gitea - $giteaRepos = []; - try { - $existing = $this->gitea->listOrgRepos($giteaOrg); - foreach ($existing as $r) { - $giteaRepos[$r['name']] = true; - } - } catch (\Exception $e) { - echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n"; - } - - $toMigrate = []; - $toSkip = []; - foreach ($ghRepos as $repo) { - $name = $repo['name']; - if (isset($giteaRepos[$name])) { - $toSkip[] = $name; - } else { - $toMigrate[] = $repo; - } - } - - echo "\nMigration plan:\n"; - echo " Migrate: " . count($toMigrate) . " repositories\n"; - echo " Skip: " . count($toSkip) . " (already on Gitea)\n"; - if (!empty($toSkip)) { - echo " Skipped: " . implode(', ', $toSkip) . "\n"; - } - echo "\n"; - - if (empty($toMigrate)) { - echo "Nothing to migrate.\n"; - return 0; - } - - if ($dryRun) { - echo "Repositories to migrate:\n"; - foreach ($toMigrate as $repo) { - $vis = $repo['private'] ? 'private' : 'public'; - echo " - {$repo['name']} ({$vis})\n"; - } - echo "\nDry run complete. Use without --dry-run to execute.\n"; - return 0; - } - - // โ”€โ”€ Phase 2: Migrate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - $this->section('Phase 2: Migration'); - - $ghToken = $config->getString('github.token'); - $results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip]; - - // Resume support - $checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null; - $startFrom = $checkpoint['last_completed'] ?? ''; - $skipUntil = !empty($startFrom); - - foreach ($toMigrate as $index => $repo) { - $name = $repo['name']; - - if ($skipUntil) { - if ($name === $startFrom) { - $skipUntil = false; - } - echo " Skipping {$name} (already migrated)\n"; - continue; - } - - echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n"; - - try { - // Shallow migration โ€” copy current branch state only, no past - // commit history. This gives every repo a clean start on Gitea. - $this->gitea->migrateRepository([ - 'clone_addr' => "https://github.com/{$org}/{$name}.git", - 'repo_name' => $name, - 'repo_owner' => $giteaOrg, - 'service' => 'github', - 'auth_token' => $ghToken, - 'mirror' => false, - 'private' => $repo['private'], - 'issues' => false, - 'labels' => true, - 'milestones' => false, - 'releases' => false, - 'pull_requests' => false, - 'wiki' => false, - ]); - - echo " Migrated successfully\n"; - $results['migrated'][] = $name; - - // Save checkpoint after each successful migration - $this->checkpoints->saveCheckpoint('gitea_migration', [ - 'last_completed' => $name, - 'migrated' => $results['migrated'], - 'failed' => $results['failed'], - ]); - } catch (\Exception $e) { - echo " FAILED: " . $e->getMessage() . "\n"; - $results['failed'][] = ['name' => $name, 'error' => $e->getMessage()]; - $this->gitea->getApiClient()->resetCircuitBreaker(); - } - } - - // โ”€โ”€ Phase 3: Post-migration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - $this->section('Phase 3: Post-migration'); - - foreach ($results['migrated'] as $name) { - echo " Post-processing {$name}...\n"; - - try { - // Apply topics from GitHub - $ghTopics = $this->github->getRepoTopics($org, $name); - if (!empty($ghTopics)) { - $this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics); - echo " Topics applied\n"; - } - - // Apply branch protection - $this->gitea->setBranchProtection($giteaOrg, $name, 'main', [ - 'required_reviews' => 1, - 'dismiss_stale' => true, - 'block_on_rejected' => true, - ]); - echo " Branch protection applied\n"; - } catch (\Exception $e) { - echo " Warning: post-processing issue: " . $e->getMessage() . "\n"; - $this->gitea->getApiClient()->resetCircuitBreaker(); - } - } - - // โ”€โ”€ Phase 4: Verification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - $this->section('Phase 4: Verification'); - - $report = "## Migration Report\n\n"; - $report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n"; - $report .= "**Source:** GitHub ({$org})\n"; - $report .= "**Destination:** Gitea ({$giteaOrg})\n\n"; - - $report .= "### Results\n\n"; - $report .= "| Status | Count |\n|--------|-------|\n"; - $report .= "| Migrated | " . count($results['migrated']) . " |\n"; - $report .= "| Failed | " . count($results['failed']) . " |\n"; - $report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n"; - - if (!empty($results['migrated'])) { - $report .= "### Migrated Repositories\n\n"; - foreach ($results['migrated'] as $name) { - $report .= "- {$name}\n"; - } - $report .= "\n"; - } - - if (!empty($results['failed'])) { - $report .= "### Failed Repositories\n\n"; - foreach ($results['failed'] as $fail) { - $report .= "- **{$fail['name']}**: {$fail['error']}\n"; - } - $report .= "\n"; - } - - echo $report; - - // Create summary issue on Gitea - try { - $this->gitea->createIssue( - $giteaOrg, - 'mokocli', - 'chore: GitHub โ†’ Gitea migration report โ€” ' . count($results['migrated']) . ' repos migrated', - $report, - ['labels' => ['automation', 'type: chore']] - ); - echo "Migration report issue created on Gitea.\n"; - } catch (\Exception $e) { - echo "Could not create report issue: " . $e->getMessage() . "\n"; - } - - echo "\nMigration complete: " . count($results['migrated']) . " migrated, " - . count($results['failed']) . " failed, " - . count($results['skipped']) . " skipped\n"; - - return count($results['failed']) > 0 ? 1 : 0; - } -} - -$script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea'); -exit($script->execute()); diff --git a/automation/push_files.php b/automation/push_files.php deleted file mode 100644 index a62bfc4..0000000 --- a/automation/push_files.php +++ /dev/null @@ -1,683 +0,0 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoPlatform.Automation - * INGROUP: MokoPlatform.Scripts - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli - * PATH: /automation/push_files.php - * BRIEF: Push one or more specific files to one or more remote repositories - */ - -declare(strict_types=1); - -require_once __DIR__ . '/../vendor/autoload.php'; -require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; - -use MokoCli\{ - ApiClient, - AuditLogger, - CliFramework, - Config, - GitPlatformAdapter, - MetricsCollector, - PlatformAdapterFactory, - ProjectTypeDetector -}; - -/** - * Targeted File Push Tool - * - * Pushes one or more specific files from mokocli templates to one or - * more remote repositories โ€” without running a full sync. - * - * Files are specified by their destination path as they appear in the target - * repository (e.g., ".github/ISSUE_TEMPLATE/config.yml"). The tool looks up - * the matching source template from the appropriate platform definition. - * - * Files may also be given as "source:destination" pairs to bypass definition - * lookup and push any arbitrary local file. - * - * Usage: - * php push_files.php --files=.github/ISSUE_TEMPLATE/config.yml --repos=MokoCRM - * php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent - * php push_files.php --files=templates/foo.txt:docs/foo.txt --repos=MyRepo --direct - */ -class PushFiles extends CliFramework -{ - public const DEFAULT_ORG = 'MokoConsulting'; - public const VERSION = '09.23.00'; - - private ApiClient $api; - private GitPlatformAdapter $adapter; - private AuditLogger $logger; - private ProjectTypeDetector $typeDetector; - - /** - * Setup command-line arguments - */ - protected function configure(): void - { - $this->setDescription('Push files to remote repositories'); - $this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG); - $this->addArgument('--repos', 'Target repos (comma-separated)', ''); - $this->addArgument('--files', 'Files to push (comma-separated)', ''); - $this->addArgument('--message', 'Custom commit message', ''); - $this->addArgument('--branch', 'Target branch for direct pushes', ''); - $this->addArgument('--direct', 'Push directly instead of PR', false); - $this->addArgument('--yes', 'Auto-confirm without prompting', false); - $this->addArgument('--no-issue', 'Skip creating tracking issue', false); - } - - /** - * Main execution - */ - protected function run(): int - { - $this->log('๐Ÿ“ฆ mokocli File Push v' . self::VERSION, 'INFO'); - - if (!$this->initializeComponents()) { - return 1; - } - - $org = $this->getArgument('--org', self::DEFAULT_ORG); - $reposArg = $this->getArgument('--repos', ''); - $filesArg = $this->getArgument('--files', ''); - $direct = $this->getArgument('--direct', false); - $autoYes = $this->getArgument('--yes', false); - - // Validate required arguments - if (empty($reposArg)) { - $this->log('โŒ --repos is required. Specify one or more repository names.', 'ERROR'); - $this->log(' Example: --repos=MokoCRM,WaasComponent', 'ERROR'); - return 1; - } - - if (empty($filesArg)) { - $this->log('โŒ --files is required. Specify destination paths or source:destination pairs.', 'ERROR'); - $this->log(' Example: --files=.github/ISSUE_TEMPLATE/config.yml', 'ERROR'); - return 1; - } - - $repos = $this->parseList($reposArg); - $files = $this->parseList($filesArg); - - $this->log("Organisation: {$org}", 'INFO'); - $this->log('Repositories: ' . implode(', ', $repos), 'INFO'); - $this->log('Files: ' . implode(', ', $files), 'INFO'); - $this->log('Mode: ' . ($direct ? 'direct commit' : 'pull request'), 'INFO'); - - // Resolve file mappings for each repo - $this->log("\n๐Ÿ” Resolving file mappings...", 'INFO'); - $repoFileMaps = $this->buildRepoFileMaps($org, $repos, $files); - - if (empty($repoFileMaps)) { - $this->log('โŒ No files could be resolved. Check file paths and platform definitions.', 'ERROR'); - return 1; - } - - // Confirm before proceeding - if (!$autoYes && !$this->confirmPush($repoFileMaps, $direct)) { - $this->log('โŒ Cancelled.', 'INFO'); - return 0; - } - - // Execute pushes - $results = $this->executePushes($org, $repoFileMaps, $direct); - - $this->displayResults($results); - - if ($results['failed'] > 0 && !isset($this->options['no-issue']) && !$this->dryRun) { - $this->createFailureIssue($org, $results); - } - - return $results['failed'] > 0 ? 1 : 0; - } - - /** - * Initialize enterprise components - */ - private function initializeComponents(): bool - { - $config = Config::load(); - - try { - $this->adapter = PlatformAdapterFactory::create($config); - $this->api = $this->adapter->getApiClient(); - $this->logger = new AuditLogger('push_files'); - $this->typeDetector = new ProjectTypeDetector($this->logger); - - $platform = $this->adapter->getPlatformName(); - $this->log("โœ“ Components initialized for platform: {$platform}", 'INFO'); - return true; - } catch (\Exception $e) { - $this->log('โŒ Failed to initialize: ' . $e->getMessage(), 'ERROR'); - return false; - } - } - - /** - * Parse a comma- or space-separated list into a clean array - */ - private function parseList(string $input): array - { - return array_values(array_filter( - array_map('trim', preg_split('/[\s,]+/', $input)), - fn($v) => $v !== '' - )); - } - - /** - * Build per-repo file maps: repo โ†’ [ [source, destination], โ€ฆ ] - * - * Each entry in $files is either: - * - "destination/path" โ†’ looked up in the platform definition - * - "source/path:destination/path" โ†’ used as-is (raw mode) - * - * @param string[] $repos - * @param string[] $files - * @return array> - */ - private function buildRepoFileMaps(string $org, array $repos, array $files): array - { - $repoRoot = dirname(__DIR__, 2); - $maps = []; - - foreach ($repos as $repo) { - // Detect the repo's platform so we load the right definition - $platform = $this->detectRepoPlatform($org, $repo); - $this->log(" {$repo}: platform = {$platform}", 'INFO'); - - $resolved = []; - foreach ($files as $fileSpec) { - if (str_contains($fileSpec, ':')) { - // Raw source:destination pair - [$src, $dest] = explode(':', $fileSpec, 2); - } else { - // Same path as source and destination - $src = $fileSpec; - $dest = $fileSpec; - } - $dest = ltrim($dest, '/'); - $srcAbs = rtrim($repoRoot, '/') . '/' . ltrim($src, '/'); - if (!file_exists($srcAbs)) { - $this->log(" โš ๏ธ Source not found for {$repo}: {$src}", 'WARN'); - continue; - } - $resolved[] = ['source' => $srcAbs, 'destination' => $dest]; - $this->log(" โœ“ {$dest}", 'INFO'); - } - - if (!empty($resolved)) { - $maps[$repo] = $resolved; - } - } - - return $maps; - } - - /** - * Detect platform for a repo via manifest or live detection. - */ - private function detectRepoPlatform(string $org, string $repo): string - { - // Read platform from repo's .mokogitea/manifest.xml via API - try { - $fileInfo = $this->adapter->getFileContents($org, $repo, '.mokogitea/manifest.xml', 'main'); - $manifestData = isset($fileInfo['content']) ? base64_decode($fileInfo['content']) : ''; - if (!empty($manifestData)) { - $xml = @simplexml_load_string($manifestData); - if ($xml !== false) { - $platform = (string)($xml->governance->platform ?? ''); - if (!empty($platform)) { - return $platform; - } - } - } - } catch (\Exception $e) { - // Fall through to local detection - } - - // Fall back to live detection - try { - $result = $this->typeDetector->detect('.'); - return $result['type'] ?? 'default'; - } catch (\Exception $e) { - $this->log(" โš ๏ธ Could not detect platform for {$repo}, using 'default'", 'WARN'); - return 'default'; - } - } - - /** - * Prompt for confirmation before pushing - * - * @param array> $repoFileMaps - */ - private function confirmPush(array $repoFileMaps, bool $direct): bool - { - if ($this->quiet) { - return true; - } - - $totalFiles = array_sum(array_map('count', $repoFileMaps)); - $totalRepos = count($repoFileMaps); - $mode = $direct ? 'direct commit' : 'PR'; - - echo "\n"; - foreach ($repoFileMaps as $repo => $entries) { - echo " {$repo}:\n"; - foreach ($entries as $entry) { - echo " โ†’ {$entry['destination']}\n"; - } - } - echo "\n"; - echo "โš ๏ธ About to push {$totalFiles} file(s) to {$totalRepos} repo(s) via {$mode}.\n"; - echo "Continue? [y/N]: "; - - $handle = fopen('php://stdin', 'r'); - $line = fgets($handle); - if ($handle) { - fclose($handle); - } - - return is_string($line) && strtolower(trim($line)) === 'y'; - } - - /** - * Execute all file pushes - * - * @param array> $repoFileMaps - * @return array{total: int, success: int, failed: int, repos: array} - */ - private function executePushes(string $org, array $repoFileMaps, bool $direct): array - { - $results = [ - 'total' => count($repoFileMaps), - 'success' => 0, - 'failed' => 0, - 'repos' => [], - ]; - - $customMessage = $this->getArgument('--message', ''); - $targetBranch = $this->getArgument('--branch', ''); - - foreach ($repoFileMaps as $repo => $entries) { - $this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO'); - - try { - // Resolve the default branch - $repoData = $this->adapter->getRepo($org, $repo); - $defaultBranch = $repoData['default_branch'] ?? 'main'; - $branch = $direct - ? ($targetBranch ?: $defaultBranch) - : $this->createSyncBranch($org, $repo, $defaultBranch); - - $pushed = 0; - foreach ($entries as $entry) { - if ($this->pushSingleFile($org, $repo, $entry['source'], $entry['destination'], $branch, $customMessage)) { - $pushed++; - $this->log(" โœ“ {$entry['destination']}", 'INFO'); - } else { - $this->log(" โœ— {$entry['destination']}", 'ERROR'); - } - } - - if ($pushed === 0) { - $results['failed']++; - $results['repos'][$repo] = 'failed'; - continue; - } - - $prNumber = null; - if (!$direct) { - $prTitle = "chore: push " . count($entries) . " file(s) from mokocli"; - $prBody = $this->buildPRBody($entries); - $pr = $this->adapter->createPullRequest( - $org, - $repo, - $prTitle, - $branch, - $defaultBranch, - $prBody, - ['assignees' => ['jmiller']] - ); - $prNumber = $pr['number'] ?? null; - $this->log(" ๐Ÿ“‹ PR #{$prNumber} created", 'INFO'); - $results['repos'][$repo] = "pr#{$prNumber}"; - } else { - $results['repos'][$repo] = 'pushed'; - } - - if (!isset($this->options['no-issue']) && !$this->dryRun) { - $this->createTargetRepoIssue($org, $repo, $entries, $prNumber, $direct ? $branch : null); - } - - $results['success']++; - } catch (\Exception $e) { - $this->log(" โœ— {$repo}: " . $e->getMessage(), 'ERROR'); - $results['failed']++; - $results['repos'][$repo] = 'failed'; - } - } - - return $results; - } - - /** - * Create a uniquely-named sync branch off the default branch - */ - private function createSyncBranch(string $org, string $repo, string $base): string - { - $branchName = 'moko/push-files-' . date('Ymd-His'); - - // Resolve the base branch to a commit SHA using the adapter - $sha = $this->adapter->resolveRef($org, $repo, $base); - - if (empty($sha)) { - throw new \RuntimeException("Cannot resolve SHA for branch {$base} in {$repo}"); - } - - $this->api->post("/repos/{$org}/{$repo}/git/refs", [ - 'ref' => "refs/heads/{$branchName}", - 'sha' => $sha, - ]); - - $this->log(" ๐ŸŒฟ Branch created: {$branchName}", 'INFO'); - return $branchName; - } - - /** - * Push a single file to a repository branch via the Contents API - * - * @return bool True on success - */ - private function pushSingleFile( - string $org, - string $repo, - string $sourcePath, - string $destPath, - string $branch, - string $customMessage - ): bool { - $content = file_get_contents($sourcePath); - if ($content === false) { - $this->log(" โš ๏ธ Cannot read source: {$sourcePath}", 'WARN'); - return false; - } - - $message = !empty($customMessage) - ? $customMessage - : "chore: update {$destPath} from mokocli"; - - // Fetch existing file SHA (needed for updates) - $existingSha = null; - try { - $existing = $this->adapter->getFileContents($org, $repo, $destPath, $branch); - $existingSha = $existing['sha'] ?? null; - } catch (\Exception $e) { - // File does not exist โ€” create it (no sha needed) - $this->adapter->getApiClient()->resetCircuitBreaker(); - } - - try { - $this->adapter->createOrUpdateFile( - $org, - $repo, - $destPath, - $content, - $message, - $existingSha, - $branch - ); - return true; - } catch (\Exception $e) { - $this->log(" โœ— API error pushing {$destPath}: " . $e->getMessage(), 'ERROR'); - return false; - } - } - - /** - * Create a tracking issue in the target repository after a successful push. - * - * @param list $entries - */ - private function createTargetRepoIssue( - string $org, - string $repo, - array $entries, - ?int $prNumber, - ?string $directBranch - ): void { - $now = gmdate('Y-m-d H:i:s') . ' UTC'; - $version = self::VERSION; - $source = $this->adapter->getRepoWebUrl($org, 'mokocli'); - - $title = "chore: mokocli file push tracking"; - - $deliveryLine = $prNumber !== null - ? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |" - : "| **Delivery** | Direct commit to `{$directBranch}` |"; - - $fileRows = implode("\n", array_map( - fn($e) => "- `{$e['destination']}`", - $entries - )); - - $body = <<api->get("/repos/{$org}/{$repo}/issues", [ - 'labels' => 'standards-update', - 'state' => 'all', - 'per_page' => 1, - 'sort' => 'created', - 'direction' => 'desc', - ]); - - $existing = array_values($existing); - if (!empty($existing) && isset($existing[0]['number'])) { - $num = $existing[0]['number']; - $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; - if (($existing[0]['state'] ?? 'open') === 'closed') { - $patch['state'] = 'open'; - } - $this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch); - try { - $this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]); - } catch (\Exception $le) { -/* non-fatal */ - } - $this->log(" ๐Ÿ“‹ Tracking issue #{$num} updated in {$repo}", 'INFO'); - } else { - $issue = $this->api->post("/repos/{$org}/{$repo}/issues", [ - 'title' => $title, - 'body' => $body, - 'labels' => $labels, - 'assignees' => ['jmiller'], - ]); - $num = $issue['number'] ?? null; - $this->log(" ๐Ÿ“‹ Tracking issue #{$num} created in {$repo}", 'INFO'); - } - - // Cross-link: patch the sync PR body to reference the tracking issue - // so GitHub shows it in the PR's Development sidebar. - if ($prNumber !== null && is_int($num)) { - try { - $pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNumber}"); - $currentBody = $pr['body'] ?? ''; - $ref = "Linked to #{$num}"; - if (!str_contains($currentBody, $ref)) { - $this->api->patch("/repos/{$org}/{$repo}/pulls/{$prNumber}", [ - 'body' => $ref . "\n\n" . $currentBody, - ]); - } - } catch (\Exception $le) { -/* non-fatal */ - } - } - } catch (\Exception $e) { - $this->log(" โš ๏ธ Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN'); - } - } - - /** - * Create or update a failure issue in mokocli when repos fail to receive files. - * Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate. - */ - private function createFailureIssue(string $org, array $results): void - { - $now = gmdate('Y-m-d H:i:s') . ' UTC'; - $failed = $results['failed']; - $version = self::VERSION; - - $failedRepos = array_keys(array_filter( - $results['repos'] ?? [], - fn($s) => $s === 'failed' - )); - - $repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos)); - $fileArgs = $this->getArgument('--files', ''); - - $title = "fix: push_files failed for {$failed} repo(s) โ€” action required"; - - $body = << --files= --yes` - 4. Close this issue once resolved. - - --- - *Auto-created by `push_files.php` โ€” close once resolved.* - MD; - - $body = preg_replace('/^ /m', '', $body); - - try { - $existing = $this->api->get("/repos/{$org}/mokocli/issues", [ - 'labels' => 'push-failure', - 'state' => 'all', - 'per_page' => 1, - 'sort' => 'created', - 'direction' => 'desc', - ]); - - $existing = array_values($existing); - if (!empty($existing) && isset($existing[0]['number'])) { - $num = $existing[0]['number']; - $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; - if (($existing[0]['state'] ?? 'open') === 'closed') { - $patch['state'] = 'open'; - } - $this->api->patch("/repos/{$org}/mokocli/issues/{$num}", $patch); - $this->log("๐Ÿšจ Failure issue #{$num} updated: {$org}/mokocli#{$num}", 'WARN'); - } else { - $issue = $this->api->post("/repos/{$org}/mokocli/issues", [ - 'title' => $title, - 'body' => $body, - 'labels' => ['push-failure'], - 'assignees' => ['jmiller'], - ]); - $num = $issue['number'] ?? '?'; - $this->log("๐Ÿšจ Failure issue created: {$org}/mokocli#{$num}", 'WARN'); - } - } catch (\Exception $e) { - $this->log("โš ๏ธ Could not create/update failure issue: " . $e->getMessage(), 'WARN'); - } - } - - /** - * Build a markdown PR body listing every pushed file - * - * @param list $entries - */ - private function buildPRBody(array $entries): string - { - $now = gmdate('Y-m-d H:i:s') . ' UTC'; - $lines = ["## mokocli File Push\n", "**Pushed:** {$now}\n", '### Files\n']; - - foreach ($entries as $entry) { - $lines[] = "- `{$entry['destination']}`"; - } - - $sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'mokocli'); - $lines[] = "\n---\n*Generated by [mokocli]({$sourceUrl}) `push_files.php`*"; - - return implode("\n", $lines); - } - - /** - * Display final results - * - * @param array{total: int, success: int, failed: int, repos: array} $results - */ - private function displayResults(array $results): void - { - $this->log("\n" . str_repeat('=', 60), 'INFO'); - $this->log('๐Ÿ“Š Push Complete', 'INFO'); - $this->log(str_repeat('=', 60), 'INFO'); - $this->log(sprintf('Total: %d repos', $results['total']), 'INFO'); - $this->log(sprintf('Success: %d', $results['success']), 'INFO'); - $this->log(sprintf('Failed: %d', $results['failed']), 'INFO'); - - if ($this->verbose) { - $this->log("\n๐Ÿ“‹ Details:", 'INFO'); - foreach ($results['repos'] as $repo => $outcome) { - $icon = str_starts_with($outcome, 'pr#') || $outcome === 'pushed' ? 'โœ“' : 'โœ—'; - $this->log(" {$icon} {$repo}: {$outcome}", 'INFO'); - } - } - - $this->log(str_repeat('=', 60), 'INFO'); - } -} - -// Execute if run directly -if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) { - $app = new PushFiles(); - exit($app->execute()); -} diff --git a/automation/push_manifest_xml.php b/automation/push_manifest_xml.php deleted file mode 100644 index 00c7a59..0000000 --- a/automation/push_manifest_xml.php +++ /dev/null @@ -1,345 +0,0 @@ -#!/usr/bin/env php - - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoPlatform.Automation - * INGROUP: MokoPlatform - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli - * PATH: /automation/push_manifest_xml.php - * BRIEF: Push XML manifests to all governed repositories - */ - -declare(strict_types=1); - -require_once __DIR__ . '/../vendor/autoload.php'; -require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; - -use MokoCli\CliFramework; -use MokoCli\ManifestParser; - -class PushManifestXmlCli extends CliFramework -{ - private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods']; - - protected function configure(): void - { - $this->setDescription('Push XML manifest.xml to all governed repositories'); - $this->addArgument('--repo', 'Filter to a single repo name', ''); - $this->addArgument('--skip', 'Comma-separated list of repos to skip', ''); - $this->addArgument('--force', 'Force overwrite even if already XML', false); - } - - protected function run(): int - { - $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); - $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; - $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; - - $force = $this->getArgument('--force'); - $repoFilter = $this->getArgument('--repo') ?: null; - $skipStr = $this->getArgument('--skip'); - $skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : []; - - $parser = new ManifestParser(); - $tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid(); - - echo "=== mokocli XML Manifest Push ===\n"; - echo "Org: {$giteaOrg}\n"; - echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n"; - if ($repoFilter) { - echo "Filter: {$repoFilter}\n"; - } - echo "\n"; - - if (empty($token)) { - $this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required'); - return 1; - } - - $repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token); - echo "Found " . count($repos) . " repositories\n\n"; - - $stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0]; - - foreach ($repos as $repo) { - $name = $repo['name']; - if ($repoFilter && $name !== $repoFilter) { - continue; - } - if (in_array($name, $skipRepos, true)) { - echo " SKIP {$name} (excluded)\n"; - $stats['skipped']++; - continue; - } - if ($repo['archived'] ?? false) { - echo " SKIP {$name} (archived)\n"; - $stats['skipped']++; - continue; - } - - $platform = $this->detectPlatform($repo); - $defaultBranch = $repo['default_branch'] ?? 'main'; - $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; - $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); - - echo " {$name} [{$platform}] ... "; - - // Generate XML manifest - $xmlContent = $parser->generate([ - 'name' => $name, - 'org' => $giteaOrg, - 'platform' => $platform, - 'standards_version' => '04.07.00', - 'description' => $repo['description'] ?? '', - 'license' => 'GPL-3.0-or-later', - 'topics' => $repo['topics'] ?? [], - 'language' => $repo['language'] ?? ManifestParser::platformLanguage($platform), - 'package_type' => ManifestParser::platformPackageType($platform), - 'last_synced' => date('c'), - ]); - - if ($this->dryRun) { - echo "WOULD WRITE ({$platform})\n"; - $stats['created']++; - continue; - } - - // Clone shallow via HTTPS (token-authed) - $workDir = "{$tmpBase}/{$name}"; - @mkdir($workDir, 0755, true); - - [$ret, $out] = $this->safeExec( - 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' ' - . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) - ); - if ($ret !== 0) { - echo "FAIL (clone)\n"; - fprintf(STDERR, " %s\n", $out); - $stats['failed']++; - continue; - } - - // Check if already XML and up-to-date - $manifestPath = "{$workDir}/.mokogitea/manifest.xml"; - $existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), 'extractPlatform(file_get_contents($manifestPath)); - if ($existingPlatform === $platform) { - echo "SKIP (already XML)\n"; - $stats['skipped']++; - $this->rmTree($workDir); - continue; - } - } - - // Write manifest - @mkdir("{$workDir}/.gitea", 0755, true); - file_put_contents($manifestPath, $xmlContent); - - // Delete legacy files if present - $legacyDeleted = []; - foreach (['.mokostandards', '.github/.mokostandards', '.gitea/.mokostandards', '.mokogitea/.mokostandards'] as $legacy) { - $legacyPath = "{$workDir}/{$legacy}"; - if (file_exists($legacyPath)) { - unlink($legacyPath); - $legacyDeleted[] = $legacy; - } - } - - // Commit - $isNew = !$existingIsXml; - $commitMsg = $isNew - ? 'chore: add XML manifest.xml' - : 'chore: update manifest.xml'; - if (!empty($legacyDeleted)) { - $commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted); - } - - $this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); - $this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); - $this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml'); - foreach ($legacyDeleted as $lf) { - $this->gitCmd($workDir, 'add', $lf); - } - - [$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg); - if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) { - echo "SKIP (no changes)\n"; - $stats['skipped']++; - $this->rmTree($workDir); - continue; - } - if ($commitRet !== 0) { - echo "FAIL (commit)\n"; - fprintf(STDERR, " %s\n", $commitOut); - $stats['failed']++; - $this->rmTree($workDir); - continue; - } - - [$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch); - if ($pushRet !== 0) { - echo "FAIL (push)\n"; - fprintf(STDERR, " %s\n", $pushOut); - $stats['failed']++; - } else { - $action = $isNew ? 'CREATED' : 'UPDATED'; - echo "{$action}\n"; - $stats[$isNew ? 'created' : 'updated']++; - } - - // Cleanup - $this->rmTree($workDir); - } - - // Cleanup tmp base - @rmdir($tmpBase); - - echo "\n=== Summary ===\n"; - echo "Created: {$stats['created']}\n"; - echo "Updated: {$stats['updated']}\n"; - echo "Skipped: {$stats['skipped']}\n"; - echo "Failed: {$stats['failed']}\n"; - - return 0; - } - - private function detectPlatform(array $repo): string - { - $name = $repo['name'] ?? ''; - $nameLower = strtolower($name); - $description = strtolower($repo['description'] ?? ''); - $topics = $repo['topics'] ?? []; - - if (in_array($name, self::CRM_PLATFORM_REPOS, true)) { - return 'crm-platform'; - } - if (in_array('dolibarr-platform', $topics)) { - return 'crm-platform'; - } - if (in_array('joomla-template', $topics)) { - return 'joomla-template'; - } - if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) { - return 'waas-component'; - } - if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) { - return 'crm-module'; - } - - if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) { - return 'joomla-template'; - } - if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) { - return 'waas-component'; - } - if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) { - return 'crm-module'; - } - - if (str_contains($description, 'joomla template')) { - return 'joomla-template'; - } - if (str_contains($description, 'joomla') || str_contains($description, 'component')) { - return 'waas-component'; - } - if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) { - return 'crm-module'; - } - - if (str_contains($nameLower, 'standard')) { - return 'standards-repository'; - } - return 'default-repository'; - } - - /** - * @return array{int, string} - */ - private function safeExec(string $command, string $cwd = '.'): array - { - $proc = proc_open( - $command, - [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], - $pipes, - $cwd - ); - if (!is_resource($proc)) { - return [1, "proc_open failed for: {$command}"]; - } - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $code = proc_close($proc); - return [$code, trim($stdout . "\n" . $stderr)]; - } - - private function rmTree(string $dir): void - { - if (!is_dir($dir)) { - return; - } - $it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); - $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); - foreach ($files as $file) { - if ($file->isDir()) { - @rmdir($file->getPathname()); - } else { - @chmod($file->getPathname(), 0777); - @unlink($file->getPathname()); - } - } - @rmdir($dir); - } - - /** - * @return array{int, string} - */ - private function gitCmd(string $workDir, string ...$args): array - { - $cmd = 'git'; - foreach ($args as $a) { - $cmd .= ' ' . escapeshellarg($a); - } - return $this->safeExec($cmd, $workDir); - } - - private function fetchRepos(string $url, string $org, string $token): array - { - $repos = []; - $page = 1; - do { - $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], - CURLOPT_TIMEOUT => 30, - ]); - $body = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($code !== 200) { - $this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}"); - break; - } - - $batch = json_decode($body, true); - if (empty($batch)) { - break; - } - $repos = array_merge($repos, $batch); - $page++; - } while (count($batch) >= 50); - - return $repos; - } -} - -$app = new PushManifestXmlCli(); -exit($app->execute()); diff --git a/automation/push_mokostandards_xml.php b/automation/push_mokostandards_xml.php deleted file mode 100644 index 4ba48f9..0000000 --- a/automation/push_mokostandards_xml.php +++ /dev/null @@ -1,345 +0,0 @@ -#!/usr/bin/env php - - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoPlatform.Automation - * INGROUP: MokoPlatform - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli - * PATH: /automation/push_mokostandards_xml.php - * BRIEF: Push XML manifests to all governed repositories - */ - -declare(strict_types=1); - -require_once __DIR__ . '/../vendor/autoload.php'; -require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; - -use MokoCli\CliFramework; -use MokoCli\ManifestParser; - -class PushMokostandardsXmlCli extends CliFramework -{ - private const CRM_PLATFORM_REPOS = ['MokoDolibarr', 'MokoDoliMods']; - - protected function configure(): void - { - $this->setDescription('Push XML manifests to all governed repositories'); - $this->addArgument('--repo', 'Filter to a single repo name', ''); - $this->addArgument('--skip', 'Comma-separated list of repos to skip', ''); - $this->addArgument('--force', 'Force overwrite even if already XML', false); - } - - protected function run(): int - { - $giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/'); - $giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting'; - $token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: ''; - - $force = $this->getArgument('--force'); - $repoFilter = $this->getArgument('--repo') ?: null; - $skipStr = $this->getArgument('--skip'); - $skipRepos = $skipStr !== '' ? array_map('trim', explode(',', $skipStr)) : []; - - $parser = new ManifestParser(); - $tmpBase = sys_get_temp_dir() . '/moko-manifest-push-' . getmypid(); - - echo "=== mokocli XML Manifest Push ===\n"; - echo "Org: {$giteaOrg}\n"; - echo "Mode: " . ($this->dryRun ? "DRY RUN" : "LIVE") . "\n"; - if ($repoFilter) { - echo "Filter: {$repoFilter}\n"; - } - echo "\n"; - - if (empty($token)) { - $this->log('ERROR', 'GA_TOKEN or GH_TOKEN environment variable required'); - return 1; - } - - $repos = $this->fetchRepos($giteaUrl, $giteaOrg, $token); - echo "Found " . count($repos) . " repositories\n\n"; - - $stats = ['created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0]; - - foreach ($repos as $repo) { - $name = $repo['name']; - if ($repoFilter && $name !== $repoFilter) { - continue; - } - if (in_array($name, $skipRepos, true)) { - echo " SKIP {$name} (excluded)\n"; - $stats['skipped']++; - continue; - } - if ($repo['archived'] ?? false) { - echo " SKIP {$name} (archived)\n"; - $stats['skipped']++; - continue; - } - - $platform = $this->detectPlatform($repo); - $defaultBranch = $repo['default_branch'] ?? 'main'; - $httpsUrl = $repo['clone_url'] ?? "{$giteaUrl}/{$giteaOrg}/{$name}.git"; - $authedUrl = preg_replace('#^https://#', "https://gitea-actions:{$token}@", $httpsUrl); - - echo " {$name} [{$platform}] ... "; - - // Generate XML manifest - $xmlContent = $parser->generate([ - 'name' => $name, - 'org' => $giteaOrg, - 'platform' => $platform, - 'standards_version' => '04.07.00', - 'description' => $repo['description'] ?? '', - 'license' => 'GPL-3.0-or-later', - 'topics' => $repo['topics'] ?? [], - 'language' => $repo['language'] ?? ManifestParser::platformLanguage($platform), - 'package_type' => ManifestParser::platformPackageType($platform), - 'last_synced' => date('c'), - ]); - - if ($this->dryRun) { - echo "WOULD WRITE ({$platform})\n"; - $stats['created']++; - continue; - } - - // Clone shallow via HTTPS (token-authed) - $workDir = "{$tmpBase}/{$name}"; - @mkdir($workDir, 0755, true); - - [$ret, $out] = $this->safeExec( - 'git clone --depth 1 --branch ' . escapeshellarg($defaultBranch) . ' ' - . escapeshellarg($authedUrl) . ' ' . escapeshellarg($workDir) - ); - if ($ret !== 0) { - echo "FAIL (clone)\n"; - fprintf(STDERR, " %s\n", $out); - $stats['failed']++; - continue; - } - - // Check if already XML and up-to-date - $manifestPath = "{$workDir}/.mokogitea/manifest.xml"; - $existingIsXml = file_exists($manifestPath) && str_contains(file_get_contents($manifestPath), 'extractPlatform(file_get_contents($manifestPath)); - if ($existingPlatform === $platform) { - echo "SKIP (already XML)\n"; - $stats['skipped']++; - $this->rmTree($workDir); - continue; - } - } - - // Write manifest - @mkdir("{$workDir}/.gitea", 0755, true); - file_put_contents($manifestPath, $xmlContent); - - // Delete legacy files if present - $legacyDeleted = []; - foreach (['.mokostandards', '.github/.mokostandards'] as $legacy) { - $legacyPath = "{$workDir}/{$legacy}"; - if (file_exists($legacyPath)) { - unlink($legacyPath); - $legacyDeleted[] = $legacy; - } - } - - // Commit - $isNew = !$existingIsXml; - $commitMsg = $isNew - ? 'chore: add XML manifest.xml' - : 'chore: update .mokostandards to XML format'; - if (!empty($legacyDeleted)) { - $commitMsg .= "\n\nRemoved legacy: " . implode(', ', $legacyDeleted); - } - - $this->gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]'); - $this->gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech'); - $this->gitCmd($workDir, 'add', '.mokogitea/manifest.xml'); - foreach ($legacyDeleted as $lf) { - $this->gitCmd($workDir, 'add', $lf); - } - - [$commitRet, $commitOut] = $this->gitCmd($workDir, 'commit', '-m', $commitMsg); - if ($commitRet !== 0 && str_contains($commitOut, 'nothing to commit')) { - echo "SKIP (no changes)\n"; - $stats['skipped']++; - $this->rmTree($workDir); - continue; - } - if ($commitRet !== 0) { - echo "FAIL (commit)\n"; - fprintf(STDERR, " %s\n", $commitOut); - $stats['failed']++; - $this->rmTree($workDir); - continue; - } - - [$pushRet, $pushOut] = $this->gitCmd($workDir, 'push', 'origin', $defaultBranch); - if ($pushRet !== 0) { - echo "FAIL (push)\n"; - fprintf(STDERR, " %s\n", $pushOut); - $stats['failed']++; - } else { - $action = $isNew ? 'CREATED' : 'UPDATED'; - echo "{$action}\n"; - $stats[$isNew ? 'created' : 'updated']++; - } - - // Cleanup - $this->rmTree($workDir); - } - - // Cleanup tmp base - @rmdir($tmpBase); - - echo "\n=== Summary ===\n"; - echo "Created: {$stats['created']}\n"; - echo "Updated: {$stats['updated']}\n"; - echo "Skipped: {$stats['skipped']}\n"; - echo "Failed: {$stats['failed']}\n"; - - return 0; - } - - private function detectPlatform(array $repo): string - { - $name = $repo['name'] ?? ''; - $nameLower = strtolower($name); - $description = strtolower($repo['description'] ?? ''); - $topics = $repo['topics'] ?? []; - - if (in_array($name, self::CRM_PLATFORM_REPOS, true)) { - return 'crm-platform'; - } - if (in_array('dolibarr-platform', $topics)) { - return 'crm-platform'; - } - if (in_array('joomla-template', $topics)) { - return 'joomla-template'; - } - if (in_array('joomla', $topics) || in_array('joomla-extension', $topics)) { - return 'waas-component'; - } - if (in_array('dolibarr', $topics) || in_array('dolibarr-module', $topics)) { - return 'crm-module'; - } - - if (str_contains($nameLower, 'template') && (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'tpl'))) { - return 'joomla-template'; - } - if (str_contains($nameLower, 'joomla') || str_contains($nameLower, 'waas')) { - return 'waas-component'; - } - if (str_contains($nameLower, 'doli') || str_contains($nameLower, 'crm')) { - return 'crm-module'; - } - - if (str_contains($description, 'joomla template')) { - return 'joomla-template'; - } - if (str_contains($description, 'joomla') || str_contains($description, 'component')) { - return 'waas-component'; - } - if (str_contains($description, 'dolibarr') || str_contains($description, 'module')) { - return 'crm-module'; - } - - if (str_contains($nameLower, 'standard')) { - return 'standards-repository'; - } - return 'default-repository'; - } - - /** - * @return array{int, string} - */ - private function safeExec(string $command, string $cwd = '.'): array - { - $proc = proc_open( - $command, - [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], - $pipes, - $cwd - ); - if (!is_resource($proc)) { - return [1, "proc_open failed for: {$command}"]; - } - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $code = proc_close($proc); - return [$code, trim($stdout . "\n" . $stderr)]; - } - - private function rmTree(string $dir): void - { - if (!is_dir($dir)) { - return; - } - $it = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS); - $files = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); - foreach ($files as $file) { - if ($file->isDir()) { - @rmdir($file->getPathname()); - } else { - @chmod($file->getPathname(), 0777); - @unlink($file->getPathname()); - } - } - @rmdir($dir); - } - - /** - * @return array{int, string} - */ - private function gitCmd(string $workDir, string ...$args): array - { - $cmd = 'git'; - foreach ($args as $a) { - $cmd .= ' ' . escapeshellarg($a); - } - return $this->safeExec($cmd, $workDir); - } - - private function fetchRepos(string $url, string $org, string $token): array - { - $repos = []; - $page = 1; - do { - $ch = curl_init("{$url}/api/v1/orgs/{$org}/repos?page={$page}&limit=50"); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTPHEADER => ["Authorization: token {$token}"], - CURLOPT_TIMEOUT => 30, - ]); - $body = curl_exec($ch); - $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if ($code !== 200) { - $this->log('ERROR', "API error (HTTP {$code}) fetching repos page {$page}"); - break; - } - - $batch = json_decode($body, true); - if (empty($batch)) { - break; - } - $repos = array_merge($repos, $batch); - $page++; - } while (count($batch) >= 50); - - return $repos; - } -} - -$app = new PushMokostandardsXmlCli(); -exit($app->execute()); diff --git a/automation/repo_cleanup.php b/automation/repo_cleanup.php deleted file mode 100644 index d6d90ab..0000000 --- a/automation/repo_cleanup.php +++ /dev/null @@ -1,517 +0,0 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoPlatform.Automation - * INGROUP: MokoPlatform.Scripts - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli - * PATH: /automation/repo_cleanup.php - * BRIEF: Enterprise repository cleanup โ€” branches, PRs, issues, workflows, labels, logs - */ - -declare(strict_types=1); - -require_once __DIR__ . '/../vendor/autoload.php'; -require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; - -use MokoCli\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory}; - -/** - * Enterprise Repository Cleanup - * - * Comprehensive maintenance tool for governed repositories: - * 1. Delete stale sync branches (keeps current versioned branch) - * 2. Close superseded PRs on deleted branches - * 3. Close/lock resolved tracking issues where linked PR is merged - * 4. Delete retired workflow files from repos - * 5. Clean cancelled/stale workflow runs - * 6. Delete workflow run logs older than N days - * 7. Verify and provision standard labels - * 8. Version drift detection - */ -class RepoCleanup extends CliFramework -{ - private const VERSION = '09.23.00'; - private const SYNC_PREFIX = 'chore/sync-mokocli-'; - private const CURRENT_BRANCH = 'chore/sync-mokocli-v04.02.00'; - - /** Workflow files that have been retired and should be deleted from governed repos. */ - private const RETIRED_WORKFLOWS = [ - 'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml', - 'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml', - 'flush-actions-cache.yml', 'mokocli-script-runner.yml', 'unified-ci.yml', - 'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml', - 'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml', - 'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml', - 'rebuild-docs-indexes.yml', 'setup-project-v2.yml', 'sync-docs-to-project.yml', - 'release.yml', 'sync-changelogs.yml', 'version_branch.yml', - 'publish-to-mokodolibarr.yml', 'ci.yml', - 'deploy-rs.yml', - ]; - - private ApiClient $api; - private GitPlatformAdapter $adapter; - protected bool $dryRun = false; - private float $startTime; - - protected function configure(): void - { - $this->setDescription('Enterprise repository cleanup'); - $this->addArgument('--org', 'GitHub organization', 'MokoConsulting'); - $this->addArgument('--repos', 'Specific repos (space-separated)', ''); - $this->addArgument('--skip-archived', 'Skip archived repos', false); - $this->addArgument('--close-issues', 'Close resolved tracking issues', false); - $this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false); - $this->addArgument('--clean-workflows', 'Delete stale workflow runs', false); - $this->addArgument('--clean-logs', 'Delete old workflow logs', false); - $this->addArgument('--log-days', 'Days to keep logs', '30'); - $this->addArgument('--delete-retired', 'Delete retired workflows', false); - $this->addArgument('--check-labels', 'Verify labels exist', false); - $this->addArgument('--check-drift', 'Check version drift', false); - $this->addArgument('--all', 'Run all operations', false); - $this->addArgument('--yes', 'Auto-confirm', false); - $this->addArgument('--json', 'Output as JSON', false); - } - - protected function run(): int - { - $this->startTime = microtime(true); - $org = $this->getArgument('--org', 'MokoConsulting'); - $this->dryRun = (bool) $this->getArgument('--dry-run', false); - $runAll = (bool) $this->getArgument('--all', false); - - $config = Config::load(); - - try { - $this->adapter = PlatformAdapterFactory::create($config); - $this->api = $this->adapter->getApiClient(); - } catch (\Exception $e) { - $this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage()); - return 1; - } - - - $this->logMsg("๐Ÿงน mokocli Repository Cleanup v" . self::VERSION); - $this->logMsg("Organization: {$org}"); - $this->logMsg("Current sync branch: " . self::CURRENT_BRANCH); - if ($this->dryRun) { - $this->logMsg("โš ๏ธ DRY RUN โ€” no changes will be made"); - } - $this->logMsg(''); - - $repos = $this->fetchRepositories($org); - $this->logMsg("Found " . count($repos) . " repositories"); - $this->logMsg(''); - - $results = [ - 'repos_processed' => 0, - 'repos_cleaned' => 0, - 'branches_deleted' => 0, - 'prs_closed' => 0, - 'issues_closed' => 0, - 'issues_locked' => 0, - 'workflows_deleted' => 0, - 'runs_deleted' => 0, - 'logs_deleted' => 0, - 'labels_missing' => 0, - 'version_drift' => 0, - 'retired_files' => 0, - 'errors' => 0, - ]; - - foreach ($repos as $i => $repo) { - $name = $repo['name']; - $num = $i + 1; - $total = count($repos); - $this->logMsg("[{$num}/{$total}] {$name}"); - $results['repos_processed']++; - - try { - $this->api->resetCircuitBreaker(); - $cleaned = false; - - // Always: delete old sync branches + close their PRs - $cleaned = $this->cleanBranches($org, $name, $results) || $cleaned; - - // Optional: close resolved issues - if ($runAll || $this->getArgument('--close-issues', false)) { - $cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned; - } - - // Optional: lock old closed issues - if ($runAll || $this->getArgument('--lock-old-issues', false)) { - $cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned; - } - - // Optional: delete retired workflow files - if ($runAll || $this->getArgument('--delete-retired', false)) { - $cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned; - } - - // Optional: clean workflow runs - if ($runAll || $this->getArgument('--clean-workflows', false)) { - $cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned; - } - - // Optional: clean old logs - if ($runAll || $this->getArgument('--clean-logs', false)) { - $cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned; - } - - // Optional: check labels - if ($runAll || $this->getArgument('--check-labels', false)) { - $this->checkLabels($org, $name, $results); - } - - // Optional: check version drift - if ($runAll || $this->getArgument('--check-drift', false)) { - $this->checkVersionDrift($org, $name, $results); - } - - if ($cleaned) { - $results['repos_cleaned']++; - } - } catch (\Exception $e) { - $this->errorMsg(" โœ— {$name}: " . $e->getMessage()); - $results['errors']++; - } - } - - $duration = round(microtime(true) - $this->startTime, 1); - - $this->logMsg(''); - $this->logMsg('============================================================'); - $this->logMsg("๐Ÿงน Cleanup Complete ({$duration}s)"); - $this->logMsg('============================================================'); - $this->logMsg("Repos processed: {$results['repos_processed']}"); - $this->logMsg("Repos with changes: {$results['repos_cleaned']}"); - $this->logMsg("Branches deleted: {$results['branches_deleted']}"); - $this->logMsg("PRs closed: {$results['prs_closed']}"); - $this->logMsg("Issues closed: {$results['issues_closed']}"); - $this->logMsg("Issues locked: {$results['issues_locked']}"); - $this->logMsg("Retired files: {$results['retired_files']}"); - $this->logMsg("Workflow runs: {$results['runs_deleted']}"); - $this->logMsg("Logs cleaned: {$results['logs_deleted']}"); - $this->logMsg("Labels missing: {$results['labels_missing']}"); - $this->logMsg("Version drift: {$results['version_drift']}"); - $this->logMsg("Errors: {$results['errors']}"); - $this->logMsg('============================================================'); - - if ($this->getArgument('--json', false)) { - $results['duration_seconds'] = $duration; - echo json_encode($results, JSON_PRETTY_PRINT) . "\n"; - } - - return $results['errors'] > 0 ? 1 : 0; - } - - // โ”€โ”€โ”€ Repository fetching โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function fetchRepositories(string $org): array - { - $specificRepos = trim((string) $this->getArgument('--repos', '')); - $skipArchived = (bool) $this->getArgument('--skip-archived', false); - - if (!empty($specificRepos)) { - $names = preg_split('/[\s,]+/', $specificRepos); - return array_map(fn($n) => ['name' => trim($n), 'archived' => false], $names); - } - - $allRepos = $this->adapter->listOrgRepos($org, $skipArchived); - return array_filter($allRepos, fn($r) => !in_array($r['name'], ['mokocli', '.github-private'], true)); - } - - // โ”€โ”€โ”€ Cleanup operations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function cleanBranches(string $org, string $repo, array &$results): bool - { - $changed = false; - try { - $branches = $this->api->get("/repos/{$org}/{$repo}/branches", ['per_page' => 100]); - } catch (\Exception $e) { - return false; - } - - foreach ($branches as $branch) { - $name = $branch['name'] ?? ''; - if (!str_starts_with($name, self::SYNC_PREFIX) || $name === self::CURRENT_BRANCH) { - continue; - } - - // Close open PRs on this branch - try { - $prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [ - 'state' => 'open', 'head' => "{$org}:{$name}", 'per_page' => 10, - ]); - foreach ($prs as $pr) { - if (($pr['number'] ?? 0) > 0 && !$this->dryRun) { - $this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']); - } - $this->logMsg(" ๐Ÿ”’ Closed PR #{$pr['number']} ({$name})"); - $results['prs_closed']++; - $changed = true; - } - } catch (\Exception $e) { -/* non-fatal */ - } - - if (!$this->dryRun) { - try { - $this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}"); - } catch (\Exception $e) { - continue; - } - } - $this->logMsg(" ๐Ÿ—‘๏ธ Deleted branch: {$name}"); - $results['branches_deleted']++; - $changed = true; - } - - return $changed; - } - - private function closeResolvedIssues(string $org, string $repo, array &$results): bool - { - $changed = false; - foreach (['standards-update', 'standards-drift'] as $label) { - try { - $issues = $this->api->get("/repos/{$org}/{$repo}/issues", [ - 'labels' => $label, 'state' => 'open', 'per_page' => 10, - ]); - } catch (\Exception $e) { - continue; - } - - foreach ($issues as $issue) { - $num = $issue['number'] ?? 0; - $body = $issue['body'] ?? ''; - if (preg_match('/\[#(\d+)\]/', $body, $m)) { - $prNum = (int) $m[1]; - try { - $pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNum}"); - if (!empty($pr['merged_at'])) { - if (!$this->dryRun) { - $this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", [ - 'state' => 'closed', 'state_reason' => 'completed', - ]); - } - $this->logMsg(" โœ… Closed issue #{$num} (PR #{$prNum} merged)"); - $results['issues_closed']++; - $changed = true; - } - } catch (\Exception $e) { -/* non-fatal */ - } - } - } - } - return $changed; - } - - private function lockOldIssues(string $org, string $repo, array &$results): bool - { - $changed = false; - $cutoff = date('Y-m-d\TH:i:s\Z', strtotime('-30 days')); - - try { - $issues = $this->api->get("/repos/{$org}/{$repo}/issues", [ - 'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc', - ]); - } catch (\Exception $e) { - return false; - } - - foreach ($issues as $issue) { - $closedAt = $issue['closed_at'] ?? ''; - $locked = $issue['locked'] ?? false; - $num = $issue['number'] ?? 0; - - if ($locked || $closedAt > $cutoff || $num === 0) { - continue; - } - - if (!$this->dryRun) { - try { - $this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [ - 'lock_reason' => 'resolved', - ]); - } catch (\Exception $e) { - continue; - } - } - $results['issues_locked']++; - $changed = true; - } - - if ($results['issues_locked'] > 0) { - $this->logMsg(" ๐Ÿ”’ Locked {$results['issues_locked']} old closed issue(s)"); - } - return $changed; - } - - private function deleteRetiredWorkflows(string $org, string $repo, array &$results): bool - { - $changed = false; - $defaultBranch = 'main'; - try { - $repoInfo = $this->api->get("/repos/{$org}/{$repo}"); - $defaultBranch = $repoInfo['default_branch'] ?? 'main'; - } catch (\Exception $e) { -/* fallback to main */ - } - - // Check both workflow directories for retired workflows (supports dual-platform repos) - $wfDirs = array_unique(['.github/workflows', '.mokogitea/workflows', $this->adapter->getWorkflowDir()]); - foreach (self::RETIRED_WORKFLOWS as $wf) { - foreach ($wfDirs as $wfDir) { - $path = "{$wfDir}/{$wf}"; - try { - $file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}"); - $sha = $file['sha'] ?? ''; - if (empty($sha)) { - continue; - } - - if (!$this->dryRun) { - $this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [ - 'message' => "chore: delete retired workflow {$wf}", - 'sha' => $sha, - 'branch' => $defaultBranch, - ]); - } - $this->logMsg(" Deleted retired: {$wf} (from {$wfDir})"); - $results['retired_files']++; - $changed = true; - } catch (\Exception $e) { - // File doesn't exist in this dir โ€” skip - $this->api->resetCircuitBreaker(); - } - } - } - return $changed; - } - - private function cleanWorkflowRuns(string $org, string $repo, array &$results): bool - { - $changed = false; - foreach (['cancelled', 'stale'] as $status) { - try { - $runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [ - 'status' => $status, 'per_page' => 100, - ]); - foreach (($runs['workflow_runs'] ?? []) as $run) { - $id = $run['id'] ?? 0; - if ($id > 0 && !$this->dryRun) { - try { - $this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}"); - $results['runs_deleted']++; - $changed = true; - } catch (\Exception $e) { - $this->api->resetCircuitBreaker(); - } - } - } - } catch (\Exception $e) { -/* non-fatal */ - } - } - if ($results['runs_deleted'] > 0) { - $this->logMsg(" ๐Ÿ”„ Cleaned {$results['runs_deleted']} workflow run(s)"); - } - return $changed; - } - - private function cleanOldLogs(string $org, string $repo, array &$results): bool - { - $changed = false; - $days = (int) $this->getArgument('--log-days', '30'); - $cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days")); - - try { - $runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [ - 'created' => "<{$cutoff}", 'per_page' => 100, - ]); - foreach (($runs['workflow_runs'] ?? []) as $run) { - $id = $run['id'] ?? 0; - if ($id > 0 && !$this->dryRun) { - try { - $this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs"); - $results['logs_deleted']++; - $changed = true; - } catch (\Exception $e) { - $this->api->resetCircuitBreaker(); - } - } - } - } catch (\Exception $e) { -/* non-fatal */ - } - - if ($results['logs_deleted'] > 0) { - $this->logMsg(" ๐Ÿ“‹ Cleaned {$results['logs_deleted']} old log(s)"); - } - return $changed; - } - - private function checkLabels(string $org, string $repo, array &$results): void - { - try { - $this->api->get("/repos/{$org}/{$repo}/labels/mokocli"); - } catch (\Exception $e) { - $this->logMsg(" โš ๏ธ Missing 'mokocli' label"); - $results['labels_missing']++; - $this->api->resetCircuitBreaker(); - } - } - - private function checkVersionDrift(string $org, string $repo, array &$results): void - { - try { - $file = $this->api->get("/repos/{$org}/{$repo}/contents/README.md"); - $content = base64_decode($file['content'] ?? ''); - if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { - $version = $m[1]; - - // Check manifest.xml for the tracked mokocli version - try { - $mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokogitea/manifest.xml"); - $mokoContent = base64_decode($mokoFile['content'] ?? ''); - if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) { - if ($vm[1] !== self::VERSION) { - $this->logMsg(" โš ๏ธ Standards drift: {$vm[1]} (expected " . self::VERSION . ")"); - $results['version_drift']++; - } - } - } catch (\Exception $e) { - $this->api->resetCircuitBreaker(); - } - } - } catch (\Exception $e) { - $this->api->resetCircuitBreaker(); - } - } - - // โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function logMsg(string $message): void - { - if (!$this->quiet) { - echo $message . "\n"; - } - } - - private function errorMsg(string $message): void - { - fwrite(STDERR, $message . "\n"); - } -} - -$app = new RepoCleanup(); -exit($app->execute()); diff --git a/automation/server-autoheal.sh b/automation/server-autoheal.sh deleted file mode 100644 index 11a0739..0000000 --- a/automation/server-autoheal.sh +++ /dev/null @@ -1,678 +0,0 @@ -#!/usr/bin/env bash -# server-autoheal.sh - Auto-heal on restart + split backup management -# -# Copyright (C) 2026 Moko Consulting -# SPDX-License-Identifier: GPL-3.0-or-later -# -# DEFGROUP: MokoPlatform.Automation.ServerAutoheal -# INGROUP: MokoPlatform.Automation -# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli -# PATH: /automation/server-autoheal.sh -# BRIEF: Server auto-heal on unclean restart + split system/content backups -# -# Usage: -# server-autoheal.sh [options] -# -# Commands: -# boot-check Run at boot โ€” auto-heals if no safe point exists -# set-safepoint Mark current state as safe (call before planned shutdown) -# backup-system Run a system backup (configs, packages, services) -# backup-content Run a content backup (site files, databases, uploads) -# cleanup Prune expired backups per retention policy -# status Show safe point and backup status -# -# Scheduling (cron): -# @reboot server-autoheal.sh boot-check -# 0 3 * * * server-autoheal.sh backup-system (daily at 3am) -# 0 */2 * * * server-autoheal.sh backup-content (every 2 hours) -# 30 */2 * * * server-autoheal.sh cleanup (30 min after content backup) - -set -euo pipefail - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Configuration โ€” override via /etc/moko/autoheal.conf -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -CONF_FILE="/etc/moko/autoheal.conf" -[[ -f "$CONF_FILE" ]] && source "$CONF_FILE" - -BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/moko}" -SAFEPOINT_FILE="${SAFEPOINT_FILE:-/var/run/moko/safepoint}" -LOG_FILE="${LOG_FILE:-/var/log/moko/autoheal.log}" -LOCK_DIR="${LOCK_DIR:-/var/run/moko}" - -# System backup: configs, package lists, service state, cron -SYSTEM_BACKUP_DIR="${BACKUP_ROOT}/system" -SYSTEM_BACKUP_RETAIN="${SYSTEM_BACKUP_RETAIN:-7}" # keep 7 daily system backups - -# Content backup: web roots, databases, uploads -CONTENT_BACKUP_DIR="${BACKUP_ROOT}/content" -CONTENT_BACKUP_RETAIN_HOURS="${CONTENT_BACKUP_RETAIN_HOURS:-24}" # 1 day of content backups - -# Paths to back up โ€” override these in /etc/moko/autoheal.conf -SYSTEM_PATHS="${SYSTEM_PATHS:-/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system}" -CONTENT_PATHS="${CONTENT_PATHS:-/var/www}" -DB_NAMES="${DB_NAMES:-}" # space-separated list, empty = auto-detect all - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Helpers -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -log() { - local level="$1"; shift - local ts - ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') - local msg="[$ts] [$level] $*" - echo "$msg" | tee -a "$LOG_FILE" >&2 -} - -ensure_dirs() { - mkdir -p "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" \ - "$LOCK_DIR" "$(dirname "$LOG_FILE")" -} - -acquire_lock() { - local lockfile="${LOCK_DIR}/autoheal-${1}.lock" - if [[ -f "$lockfile" ]]; then - local pid - pid=$(<"$lockfile") - if kill -0 "$pid" 2>/dev/null; then - log WARN "Another $1 operation is running (PID $pid), skipping" - exit 0 - fi - rm -f "$lockfile" - fi - echo $$ > "$lockfile" - trap "rm -f '$lockfile'" EXIT -} - -timestamp() { - date -u '+%Y%m%d_%H%M%S' -} - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Safe-point management -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -cmd_set_safepoint() { - ensure_dirs - local ts - ts=$(timestamp) - cat > "$SAFEPOINT_FILE" </dev/null || echo "unknown") -set_by=${SUDO_USER:-$(whoami)} -EOF - log INFO "Safe point set at $ts by ${SUDO_USER:-$(whoami)}" -} - -cmd_clear_safepoint() { - rm -f "$SAFEPOINT_FILE" - log INFO "Safe point cleared" -} - -has_safepoint() { - [[ -f "$SAFEPOINT_FILE" ]] -} - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# System backup (daily) -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -cmd_backup_system() { - ensure_dirs - acquire_lock "system-backup" - - local ts - ts=$(timestamp) - local archive="${SYSTEM_BACKUP_DIR}/system_${ts}.tar.gz" - local manifest="${SYSTEM_BACKUP_DIR}/system_${ts}.manifest" - - log INFO "Starting system backup โ†’ $archive" - - # Collect existing paths only - local existing_paths=() - for p in $SYSTEM_PATHS; do - [[ -e "$p" ]] && existing_paths+=("$p") - done - - if [[ ${#existing_paths[@]} -eq 0 ]]; then - log WARN "No system paths found to back up" - return 1 - fi - - # Archive configs and system files - tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true - - # Capture package list and service state as manifest - { - echo "=== PACKAGES ===" - if command -v dpkg &>/dev/null; then - dpkg --get-selections - elif command -v rpm &>/dev/null; then - rpm -qa --qf '%{NAME}\t%{VERSION}\n' - fi - echo "" - echo "=== ENABLED SERVICES ===" - if command -v systemctl &>/dev/null; then - systemctl list-unit-files --state=enabled --no-pager 2>/dev/null || true - fi - echo "" - echo "=== CRONTABS ===" - for user_home in /var/spool/cron/crontabs/*; do - [[ -f "$user_home" ]] && echo "--- $(basename "$user_home") ---" && cat "$user_home" - done 2>/dev/null || true - } > "$manifest" - - local size - size=$(du -sh "$archive" 2>/dev/null | cut -f1) - log INFO "System backup complete: $archive ($size)" - - # Prune old system backups (keep $SYSTEM_BACKUP_RETAIN) - local count - count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' | wc -l) - if [[ "$count" -gt "$SYSTEM_BACKUP_RETAIN" ]]; then - local to_remove=$((count - SYSTEM_BACKUP_RETAIN)) - find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \ - | sort | head -n "$to_remove" | awk '{print $2}' \ - | while read -r f; do - rm -f "$f" "${f%.tar.gz}.manifest" - log INFO "Pruned old system backup: $f" - done - fi -} - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Content backup (every 2 hours) -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -cmd_backup_content() { - ensure_dirs - acquire_lock "content-backup" - - local ts - ts=$(timestamp) - local archive="${CONTENT_BACKUP_DIR}/content_${ts}.tar.gz" - local db_dump="${CONTENT_BACKUP_DIR}/content_${ts}.sql.gz" - - log INFO "Starting content backup โ†’ $archive" - - # Back up web content / uploads - local existing_paths=() - for p in $CONTENT_PATHS; do - [[ -e "$p" ]] && existing_paths+=("$p") - done - - if [[ ${#existing_paths[@]} -gt 0 ]]; then - tar -czf "$archive" "${existing_paths[@]}" 2>/dev/null || true - local size - size=$(du -sh "$archive" 2>/dev/null | cut -f1) - log INFO "Content files archived: $archive ($size)" - else - log WARN "No content paths found to back up" - fi - - # Database dump - if command -v mysqldump &>/dev/null || command -v mariadb-dump &>/dev/null; then - local dump_cmd="mysqldump" - command -v mariadb-dump &>/dev/null && dump_cmd="mariadb-dump" - - local databases=() - if [[ -n "$DB_NAMES" ]]; then - read -ra databases <<< "$DB_NAMES" - else - # Auto-detect: dump all databases except system ones - databases=($(${dump_cmd%dump} -N -e \ - "SELECT schema_name FROM information_schema.schemata - WHERE schema_name NOT IN ('information_schema','performance_schema','mysql','sys')" \ - 2>/dev/null | tr '\n' ' ')) || true - fi - - if [[ ${#databases[@]} -gt 0 ]]; then - $dump_cmd --single-transaction --routines --triggers \ - --databases "${databases[@]}" 2>/dev/null \ - | gzip > "$db_dump" - local db_size - db_size=$(du -sh "$db_dump" 2>/dev/null | cut -f1) - log INFO "Database dump complete: $db_dump ($db_size)" - else - log WARN "No databases found to dump" - fi - fi -} - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Cleanup โ€” prune content backups older than retention -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -cmd_cleanup() { - ensure_dirs - local before_count after_count - - # Content: keep only last 24 hours (1 day) - before_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l) - find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f \ - -mmin +$((CONTENT_BACKUP_RETAIN_HOURS * 60)) -delete 2>/dev/null || true - after_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*' -type f | wc -l) - local removed=$((before_count - after_count)) - [[ "$removed" -gt 0 ]] && log INFO "Pruned $removed content backup(s) older than ${CONTENT_BACKUP_RETAIN_HOURS}h" - - # System: keep N most recent (handled in backup-system, but double-check here) - before_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f | wc -l) - local max_system_files=$((SYSTEM_BACKUP_RETAIN * 2)) # .tar.gz + .manifest - if [[ "$before_count" -gt "$max_system_files" ]]; then - local excess=$((before_count - max_system_files)) - find "$SYSTEM_BACKUP_DIR" -name 'system_*' -type f -printf '%T+ %p\n' \ - | sort | head -n "$excess" | awk '{print $2}' \ - | xargs -r rm -f - log INFO "Pruned excess system backups" - fi - - log INFO "Cleanup complete" -} - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Boot check โ€” the auto-heal entry point -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -cmd_boot_check() { - ensure_dirs - acquire_lock "boot-check" - - log INFO "=== Boot check started ===" - log INFO "Hostname: $(hostname), Kernel: $(uname -r)" - - if has_safepoint; then - log INFO "Safe point found โ€” server was shut down cleanly" - log INFO "Clearing safe point for next cycle" - cmd_clear_safepoint - log INFO "=== Boot check passed (clean restart) ===" - return 0 - fi - - log WARN "NO safe point found โ€” server restarted without clean shutdown" - log WARN "Initiating auto-heal sequence..." - - auto_heal - local rc=$? - - # Set safe point after successful heal - if [[ $rc -eq 0 ]]; then - cmd_set_safepoint - log INFO "=== Boot check complete (healed successfully) ===" - else - log ERROR "=== Boot check FAILED โ€” manual intervention required ===" - fi - - return $rc -} - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Auto-heal strategy -# -# TODO: This is the core decision point. Implement the recovery -# steps that match your server's architecture. See guidance below. -# -# Trade-offs to consider: -# - Restore-from-backup: safest, but content may be up to 2h stale -# - Service-restart-only: faster, keeps current data, but won't fix -# corrupted configs or broken filesystem state -# - Hybrid: restart services first, verify health, only restore if -# health checks fail โ€” best of both worlds but more complex -# -# The function receives no arguments. Use the latest system + content -# backups to restore if needed. Return 0 on success, 1 on failure. -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -auto_heal() { - log INFO "Phase 1: Verify and repair filesystem" - # Check for common post-crash issues - repair_filesystem - - log INFO "Phase 2: Restore system configuration if corrupted" - restore_system_if_needed - - log INFO "Phase 3: Restart core services" - restart_services - - log INFO "Phase 4: Verify health" - if ! verify_health; then - log WARN "Health check failed after service restart โ€” restoring from backup" - restore_from_backup - restart_services - - if ! verify_health; then - log ERROR "Health check still failing after restore โ€” giving up" - return 1 - fi - fi - - log INFO "Auto-heal completed successfully" - return 0 -} - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Heal sub-steps -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -repair_filesystem() { - # Fix common post-crash filesystem issues - # Clear stale PID/lock/socket files that prevent services from starting - local stale_files=( - /var/run/nginx.pid - /var/run/mysqld/mysqld.pid - /var/run/php-fpm.pid - /var/lib/mysql/*.pid - ) - for f in "${stale_files[@]}"; do - for expanded in $f; do - if [[ -f "$expanded" ]]; then - local pid - pid=$(<"$expanded") 2>/dev/null || true - if [[ -n "$pid" ]] && ! kill -0 "$pid" 2>/dev/null; then - rm -f "$expanded" - log INFO "Removed stale PID file: $expanded" - fi - fi - done - done - - # Fix permissions on critical dirs that may get mangled - [[ -d /var/run/mysqld ]] && chown mysql:mysql /var/run/mysqld 2>/dev/null || true - [[ -d /var/lib/php/sessions ]] && chmod 1733 /var/lib/php/sessions 2>/dev/null || true - - # Repair tmp/cache dirs - for d in /tmp /var/tmp; do - [[ -d "$d" ]] && chmod 1777 "$d" 2>/dev/null || true - done -} - -restore_system_if_needed() { - # Find latest system backup - local latest_system - latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \ - 2>/dev/null | sort -r | head -1 | awk '{print $2}') - - if [[ -z "$latest_system" ]]; then - log WARN "No system backup available to verify against" - return 0 - fi - - # Check if critical configs exist and are non-empty - local needs_restore=false - local critical_configs=("/etc/nginx/nginx.conf" "/etc/php" "/etc/mysql") - - for cfg in "${critical_configs[@]}"; do - if [[ -e "$cfg" ]]; then - # Config exists โ€” check if it's a file and non-empty, or a directory - if [[ -f "$cfg" && ! -s "$cfg" ]]; then - log WARN "Critical config is empty: $cfg" - needs_restore=true - break - fi - fi - done - - if $needs_restore; then - log WARN "Restoring system config from $latest_system" - tar -xzf "$latest_system" -C / 2>/dev/null || { - log ERROR "System restore failed from $latest_system" - return 1 - } - log INFO "System config restored" - else - log INFO "System configs look intact โ€” skipping restore" - fi -} - -restart_services() { - if ! command -v systemctl &>/dev/null; then - log WARN "systemctl not available โ€” skipping service restart" - return 0 - fi - - local services=("mysql" "mariadb" "nginx" "apache2" "php-fpm" "php8.1-fpm" "php8.2-fpm" "php8.3-fpm") - - for svc in "${services[@]}"; do - if systemctl is-enabled "$svc" &>/dev/null; then - log INFO "Restarting $svc..." - systemctl restart "$svc" 2>/dev/null && \ - log INFO "$svc restarted OK" || \ - log WARN "$svc restart failed" - fi - done -} - -verify_health() { - local failures=0 - - # Check critical services are running - local services=("mysql" "mariadb" "nginx" "apache2") - for svc in "${services[@]}"; do - if systemctl is-enabled "$svc" &>/dev/null; then - if ! systemctl is-active "$svc" &>/dev/null; then - log WARN "Service not running: $svc" - ((failures++)) - fi - fi - done - - # Check if web server responds - if command -v curl &>/dev/null; then - if ! curl -sf -o /dev/null --max-time 10 "http://localhost/" 2>/dev/null; then - log WARN "Local web server not responding" - ((failures++)) - fi - fi - - # Check if database accepts connections - if command -v mysqladmin &>/dev/null; then - if ! mysqladmin ping --silent 2>/dev/null; then - log WARN "Database not responding to ping" - ((failures++)) - fi - fi - - [[ $failures -eq 0 ]] -} - -restore_from_backup() { - log WARN "=== Full restore from backup ===" - - # Restore system config - local latest_system - latest_system=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \ - 2>/dev/null | sort -r | head -1 | awk '{print $2}') - - if [[ -n "$latest_system" ]]; then - log INFO "Restoring system from $latest_system" - tar -xzf "$latest_system" -C / 2>/dev/null || \ - log ERROR "System restore failed" - fi - - # Restore content - local latest_content - latest_content=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \ - 2>/dev/null | sort -r | head -1 | awk '{print $2}') - - if [[ -n "$latest_content" ]]; then - log INFO "Restoring content from $latest_content" - tar -xzf "$latest_content" -C / 2>/dev/null || \ - log ERROR "Content restore failed" - fi - - # Restore database - local latest_db - latest_db=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.sql.gz' -printf '%T+ %p\n' \ - 2>/dev/null | sort -r | head -1 | awk '{print $2}') - - if [[ -n "$latest_db" ]]; then - log INFO "Restoring database from $latest_db" - local mysql_cmd="mysql" - command -v mariadb &>/dev/null && mysql_cmd="mariadb" - zcat "$latest_db" | $mysql_cmd 2>/dev/null || \ - log ERROR "Database restore failed" - fi -} - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Status -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -cmd_status() { - echo "=== Moko Server Auto-Heal Status ===" - echo "" - - # Safe point - if has_safepoint; then - echo "Safe point: SET" - cat "$SAFEPOINT_FILE" | sed 's/^/ /' - else - echo "Safe point: NOT SET (will auto-heal on next boot)" - fi - echo "" - - # System backups - echo "System backups (${SYSTEM_BACKUP_DIR}):" - local sys_count - sys_count=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' 2>/dev/null | wc -l) - echo " Count: $sys_count (retain $SYSTEM_BACKUP_RETAIN)" - local latest_sys - latest_sys=$(find "$SYSTEM_BACKUP_DIR" -name 'system_*.tar.gz' -printf '%T+ %p\n' \ - 2>/dev/null | sort -r | head -1) - if [[ -n "$latest_sys" ]]; then - echo " Latest: $(echo "$latest_sys" | awk '{print $2}')" - echo " Timestamp: $(echo "$latest_sys" | awk '{print $1}')" - else - echo " Latest: (none)" - fi - echo "" - - # Content backups - echo "Content backups (${CONTENT_BACKUP_DIR}):" - local cnt_count - cnt_count=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' 2>/dev/null | wc -l) - echo " Count: $cnt_count (retain ${CONTENT_BACKUP_RETAIN_HOURS}h)" - local latest_cnt - latest_cnt=$(find "$CONTENT_BACKUP_DIR" -name 'content_*.tar.gz' -printf '%T+ %p\n' \ - 2>/dev/null | sort -r | head -1) - if [[ -n "$latest_cnt" ]]; then - echo " Latest: $(echo "$latest_cnt" | awk '{print $2}')" - echo " Timestamp: $(echo "$latest_cnt" | awk '{print $1}')" - else - echo " Latest: (none)" - fi - echo "" - - # Disk usage - echo "Backup disk usage:" - du -sh "$SYSTEM_BACKUP_DIR" "$CONTENT_BACKUP_DIR" 2>/dev/null | sed 's/^/ /' -} - -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Install helper โ€” sets up cron + systemd -# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -cmd_install() { - local script_path - script_path=$(readlink -f "$0") - - echo "Installing Moko Auto-Heal..." - - # Create config directory - mkdir -p /etc/moko "$(dirname "$LOG_FILE")" "$LOCK_DIR" - - # Write example config if none exists - if [[ ! -f "$CONF_FILE" ]]; then - cat > "$CONF_FILE" <<'CONF' -# /etc/moko/autoheal.conf โ€” Server auto-heal configuration -# Uncomment and modify as needed - -# BACKUP_ROOT="/var/backups/moko" -# SAFEPOINT_FILE="/var/run/moko/safepoint" -# LOG_FILE="/var/log/moko/autoheal.log" - -# System backup paths (space-separated) -# SYSTEM_PATHS="/etc/nginx /etc/php /etc/mysql /etc/cron.d /etc/systemd/system" - -# Content backup paths (space-separated) -# CONTENT_PATHS="/var/www" - -# Database names (space-separated, empty = auto-detect all) -# DB_NAMES="" - -# Retention -# SYSTEM_BACKUP_RETAIN=7 # daily backups to keep -# CONTENT_BACKUP_RETAIN_HOURS=24 # hours of content backups to keep -CONF - echo " Created config: $CONF_FILE" - fi - - # Install cron jobs - local cron_file="/etc/cron.d/moko-autoheal" - cat > "$cron_file" < "$shutdown_hook" <&2 - echo "Run '$0 help' for usage" >&2 - exit 1 - ;; - esac -} - -main "$@" diff --git a/automation/update_dependencies.php b/automation/update_dependencies.php deleted file mode 100644 index b12947b..0000000 --- a/automation/update_dependencies.php +++ /dev/null @@ -1,633 +0,0 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: MokoPlatform.Automation - * INGROUP: MokoPlatform.Scripts - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli - * PATH: /automation/update_dependencies.php - * VERSION: 09.38.00 - * BRIEF: Cross-repo dependency update automation โ€” scan, update, PR, auto-merge - */ - -declare(strict_types=1); - -require_once __DIR__ . '/../vendor/autoload.php'; - -use MokoCli\{ - ApiClient, - AuditLogger, - CheckpointManager, - CircuitBreakerOpen, - CliFramework, - Config, - GitPlatformAdapter, - PlatformAdapterFactory, - RateLimitExceeded -}; - -/** - * Cross-Repo Dependency Update Automation - * - * Scans org repos for outdated Composer/npm dependencies, creates PRs with - * changelogs, and optionally auto-merges safe patch updates. - * - * @see https://git.mokoconsulting.tech/MokoConsulting/mokocli/issues/149 - */ -class UpdateDependencies extends CliFramework -{ - public const VERSION = '01.00.00'; - - private const BRANCH_PREFIX = 'chore/deps-update'; - - private ApiClient $api; - private GitPlatformAdapter $adapter; - private AuditLogger $logger; - private CheckpointManager $checkpoints; - - /** Summary counters. */ - private int $reposScanned = 0; - private int $reposUpdated = 0; - private int $prsCreated = 0; - private int $autoMerged = 0; - private int $reposFailed = 0; - - protected function configure(): void - { - $this->setDescription('Cross-repo dependency update automation'); - $this->addArgument('--org', 'Organization to scan', 'MokoConsulting'); - $this->addArgument('--repos', 'Comma-separated list of specific repos', ''); - $this->addArgument('--exclude', 'Comma-separated list of repos to exclude', ''); - $this->addArgument('--skip-archived', 'Skip archived repositories', true); - $this->addArgument('--type', 'Dependency type: composer, npm, or all', 'all'); - $this->addArgument('--patch-only', 'Only update patch versions (safe updates)', false); - $this->addArgument('--auto-merge', 'Auto-merge PRs with only patch updates', false); - $this->addArgument('--resume', 'Resume from checkpoint', false); - } - - protected function run(): int - { - $this->log("Dependency Update Automation v" . self::VERSION, 'INFO'); - - if (!$this->initComponents()) { - return self::EXIT_FAILURE; - } - - $org = $this->getArgument('--org', 'MokoConsulting'); - $depType = strtolower($this->getArgument('--type', 'all')); - $patchOnly = $this->getArgument('--patch-only', false); - $autoMerge = $this->getArgument('--auto-merge', false); - - // โ”€โ”€ Gather repos โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - $repos = $this->gatherRepos($org); - if ($repos === null) { - return self::EXIT_FAILURE; - } - - $total = count($repos); - $this->log("Found {$total} repositories to scan", 'INFO'); - - // โ”€โ”€ Resume support โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - $completed = []; - if ($this->getArgument('--resume', false)) { - $checkpoint = $this->checkpoints->load('deps_update'); - if ($checkpoint) { - $completed = $checkpoint['completed'] ?? []; - $this->log("Resuming โ€” skipping " . count($completed) . " already-processed repos", 'INFO'); - } - } - - // โ”€โ”€ Process each repo โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - $this->section('Scanning repositories for outdated dependencies'); - - foreach ($repos as $i => $repo) { - $repoName = $repo['name']; - $this->progress($i + 1, $total, $repoName); - - if (in_array($repoName, $completed, true)) { - continue; - } - - try { - $this->processRepo($org, $repoName, $depType, $patchOnly, $autoMerge); - $completed[] = $repoName; - - $this->checkpoints->save('deps_update', ['completed' => $completed]); - } catch (RateLimitExceeded $e) { - $this->log("Rate limit hit โ€” checkpoint saved", 'WARNING'); - break; - } catch (CircuitBreakerOpen $e) { - $this->log("Circuit breaker open โ€” checkpoint saved", 'WARNING'); - break; - } catch (\Exception $e) { - $this->log("Failed {$repoName}: {$e->getMessage()}", 'ERROR'); - $this->reposFailed++; - } - } - - $this->progress($total, $total, '', true); - - // โ”€โ”€ Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - $this->section('Summary'); - $this->printSummary( - $this->reposScanned - $this->reposFailed, - $this->reposFailed, - $this->elapsed() - ); - - $this->log("Repos scanned: {$this->reposScanned}", 'INFO'); - $this->log("Repos updated: {$this->reposUpdated}", 'INFO'); - $this->log("PRs created: {$this->prsCreated}", 'INFO'); - if ($autoMerge) { - $this->log("Auto-merged: {$this->autoMerged}", 'INFO'); - } - - if (count($completed) === $total) { - $this->checkpoints->clear('deps_update'); - } - - return $this->reposFailed > 0 ? self::EXIT_FAILURE : self::EXIT_SUCCESS; - } - - // โ”€โ”€ Component init โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function initComponents(): bool - { - try { - $config = new Config(); - $this->api = new ApiClient($config); - $this->adapter = PlatformAdapterFactory::create($this->api, $config); - $this->logger = new AuditLogger(); - $this->checkpoints = new CheckpointManager(); - return true; - } catch (\Exception $e) { - $this->log("Failed to initialise: {$e->getMessage()}", 'ERROR'); - return false; - } - } - - // โ”€โ”€ Repo gathering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function gatherRepos(string $org): ?array - { - $specificRepos = array_filter(explode(',', $this->getArgument('--repos', ''))); - $excludeRepos = array_filter(explode(',', $this->getArgument('--exclude', ''))); - $skipArchived = $this->getArgument('--skip-archived', true); - - // Default exclusions - $excludeRepos = array_merge($excludeRepos, [ - 'mokocli', '.mokogitea-private', 'org-profile', - ]); - - try { - $repos = $this->adapter->listOrgRepos($org, $skipArchived); - } catch (\Exception $e) { - $this->log("Failed to list repos: {$e->getMessage()}", 'ERROR'); - return null; - } - - if (!empty($specificRepos)) { - $repos = array_filter($repos, fn($r) => in_array($r['name'], $specificRepos, true)); - } - if (!empty($excludeRepos)) { - $repos = array_filter($repos, fn($r) => !in_array($r['name'], $excludeRepos, true)); - } - - return array_values($repos); - } - - // โ”€โ”€ Per-repo processing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function processRepo( - string $org, - string $repoName, - string $depType, - bool $patchOnly, - bool $autoMerge - ): void { - $this->reposScanned++; - - $hasComposer = ($depType === 'all' || $depType === 'composer'); - $hasNpm = ($depType === 'all' || $depType === 'npm'); - - $outdated = []; - - // โ”€โ”€ Composer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if ($hasComposer) { - $composerOutdated = $this->scanComposer($org, $repoName, $patchOnly); - if ($composerOutdated !== null) { - $outdated['composer'] = $composerOutdated; - } - } - - // โ”€โ”€ npm โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if ($hasNpm) { - $npmOutdated = $this->scanNpm($org, $repoName, $patchOnly); - if ($npmOutdated !== null) { - $outdated['npm'] = $npmOutdated; - } - } - - if (empty($outdated)) { - return; - } - - // Check if there's already an open deps PR - if ($this->hasExistingDepsPR($org, $repoName)) { - $this->log(" {$repoName}: existing deps PR found โ€” skipping", 'INFO'); - return; - } - - $this->reposUpdated++; - - // โ”€โ”€ Create PR โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - $totalUpdates = 0; - $allPatchOnly = true; - - foreach ($outdated as $type => $packages) { - $totalUpdates += count($packages); - foreach ($packages as $pkg) { - if (!$this->isPatchUpdate($pkg['current'] ?? '', $pkg['latest'] ?? '')) { - $allPatchOnly = false; - } - } - } - - $title = "chore(deps): update {$totalUpdates} " . ($totalUpdates === 1 ? 'dependency' : 'dependencies'); - $body = $this->buildPrBody($repoName, $outdated); - $branch = self::BRANCH_PREFIX . '-' . date('Y-m-d'); - - if ($this->dryRun) { - $this->log("[dry-run] Would create PR in {$repoName}: {$title}", 'INFO'); - foreach ($outdated as $type => $packages) { - foreach ($packages as $pkg) { - $this->log(" [{$type}] {$pkg['name']}: {$pkg['current']} โ†’ {$pkg['latest']}", 'INFO'); - } - } - return; - } - - try { - // Clone repo, run updates, push branch - $prNumber = $this->cloneUpdateAndPR($org, $repoName, $branch, $title, $body, $outdated); - - if ($prNumber > 0) { - $this->prsCreated++; - $this->log(" {$repoName}: PR #{$prNumber} created", 'INFO'); - - // Auto-merge if all updates are patch-level - if ($autoMerge && $allPatchOnly && $prNumber > 0) { - $this->tryAutoMerge($org, $repoName, $prNumber); - } - } - } catch (\Exception $e) { - $this->log(" {$repoName}: PR creation failed โ€” {$e->getMessage()}", 'ERROR'); - } - } - - // โ”€โ”€ Composer scanning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function scanComposer(string $org, string $repoName, bool $patchOnly): ?array - { - // Check if repo has composer.json - try { - $this->adapter->getFileContents($org, $repoName, 'composer.json'); - } catch (\Exception $e) { - return null; - } - - // Check if repo has composer.lock - try { - $this->adapter->getFileContents($org, $repoName, 'composer.lock'); - } catch (\Exception $e) { - return null; - } - - // Clone to temp dir and run composer outdated - $tmpDir = sys_get_temp_dir() . '/moko_deps_' . $repoName . '_' . getmypid(); - @mkdir($tmpDir, 0700, true); - - try { - $cloneUrl = $this->adapter->getCloneUrl($org, $repoName); - $cmd = sprintf( - 'git clone --depth 1 --quiet %s %s 2>/dev/null', - escapeshellarg($cloneUrl), - escapeshellarg($tmpDir) - ); - exec($cmd, $output, $exitCode); - if ($exitCode !== 0) { - return null; - } - - // Run composer outdated - $flags = $patchOnly ? '--minor-only' : ''; - $cmd = sprintf( - 'composer outdated --format=json --no-interaction %s --working-dir=%s 2>/dev/null', - $flags, - escapeshellarg($tmpDir) - ); - $json = shell_exec($cmd); - if ($json === null || $json === '') { - return null; - } - - $data = json_decode($json, true); - $installed = $data['installed'] ?? []; - - if (empty($installed)) { - return null; - } - - $outdated = []; - foreach ($installed as $pkg) { - // Skip abandoned/dev packages - if (($pkg['abandoned'] ?? false) || str_starts_with($pkg['version'] ?? '', 'dev-')) { - continue; - } - - $outdated[] = [ - 'name' => $pkg['name'] ?? '', - 'current' => $pkg['version'] ?? '', - 'latest' => $pkg['latest'] ?? '', - 'status' => $pkg['latest-status'] ?? 'unknown', - ]; - } - - return empty($outdated) ? null : $outdated; - } finally { - // Cleanup - if (is_dir($tmpDir)) { - exec(sprintf('rm -rf %s', escapeshellarg($tmpDir))); - } - } - } - - // โ”€โ”€ npm scanning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function scanNpm(string $org, string $repoName, bool $patchOnly): ?array - { - // Check if repo has package.json - try { - $this->adapter->getFileContents($org, $repoName, 'package.json'); - } catch (\Exception $e) { - return null; - } - - // Check for lock file - $hasLock = false; - foreach (['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'] as $lockFile) { - try { - $this->adapter->getFileContents($org, $repoName, $lockFile); - $hasLock = true; - break; - } catch (\Exception $e) { - // continue - } - } - - if (!$hasLock) { - return null; - } - - $tmpDir = sys_get_temp_dir() . '/moko_deps_npm_' . $repoName . '_' . getmypid(); - @mkdir($tmpDir, 0700, true); - - try { - $cloneUrl = $this->adapter->getCloneUrl($org, $repoName); - exec(sprintf('git clone --depth 1 --quiet %s %s 2>/dev/null', - escapeshellarg($cloneUrl), escapeshellarg($tmpDir))); - - if (!file_exists("{$tmpDir}/package.json")) { - return null; - } - - // Install deps first (needed for npm outdated) - exec(sprintf('cd %s && npm install --silent 2>/dev/null', escapeshellarg($tmpDir))); - - $json = shell_exec(sprintf('cd %s && npm outdated --json 2>/dev/null', escapeshellarg($tmpDir))); - if ($json === null || $json === '' || $json === '{}') { - return null; - } - - $data = json_decode($json, true); - if (!is_array($data) || empty($data)) { - return null; - } - - $outdated = []; - foreach ($data as $name => $info) { - $current = $info['current'] ?? ''; - $wanted = $info['wanted'] ?? ''; - $latest = $info['latest'] ?? ''; - $target = $patchOnly ? $wanted : $latest; - - if ($current === $target || $target === '') { - continue; - } - - $outdated[] = [ - 'name' => $name, - 'current' => $current, - 'latest' => $target, - 'status' => ($current === $wanted) ? 'up-to-date' : 'outdated', - ]; - } - - return empty($outdated) ? null : $outdated; - } finally { - if (is_dir($tmpDir)) { - exec(sprintf('rm -rf %s', escapeshellarg($tmpDir))); - } - } - } - - // โ”€โ”€ PR creation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function cloneUpdateAndPR( - string $org, - string $repoName, - string $branch, - string $title, - string $body, - array $outdated - ): int { - $tmpDir = sys_get_temp_dir() . '/moko_deps_pr_' . $repoName . '_' . getmypid(); - @mkdir($tmpDir, 0700, true); - - try { - $cloneUrl = $this->adapter->getCloneUrl($org, $repoName); - exec(sprintf('git clone --quiet %s %s 2>/dev/null', - escapeshellarg($cloneUrl), escapeshellarg($tmpDir))); - - // Create branch - exec(sprintf('git -C %s checkout -b %s 2>/dev/null', - escapeshellarg($tmpDir), escapeshellarg($branch))); - - $updated = false; - - // Run composer update if needed - if (isset($outdated['composer'])) { - $packages = array_column($outdated['composer'], 'name'); - $cmd = sprintf( - 'cd %s && composer update %s --no-interaction --quiet 2>/dev/null', - escapeshellarg($tmpDir), - implode(' ', array_map('escapeshellarg', $packages)) - ); - exec($cmd, $output, $exitCode); - if ($exitCode === 0) { - $updated = true; - } - } - - // Run npm update if needed - if (isset($outdated['npm'])) { - $packages = array_column($outdated['npm'], 'name'); - $cmd = sprintf( - 'cd %s && npm update %s --save 2>/dev/null', - escapeshellarg($tmpDir), - implode(' ', array_map('escapeshellarg', $packages)) - ); - exec($cmd, $output, $exitCode); - if ($exitCode === 0) { - $updated = true; - } - } - - if (!$updated) { - return 0; - } - - // Commit and push - exec(sprintf('git -C %s config user.email "gitea-actions[bot]@mokoconsulting.tech"', escapeshellarg($tmpDir))); - exec(sprintf('git -C %s config user.name "gitea-actions[bot]"', escapeshellarg($tmpDir))); - exec(sprintf('git -C %s add -A', escapeshellarg($tmpDir))); - - // Check if there are actual changes - exec(sprintf('git -C %s diff --cached --quiet', escapeshellarg($tmpDir)), $output, $diffExit); - if ($diffExit === 0) { - return 0; // No changes - } - - exec(sprintf('git -C %s commit -m %s', - escapeshellarg($tmpDir), - escapeshellarg($title . " [skip ci]"))); - exec(sprintf('git -C %s push origin %s 2>/dev/null', - escapeshellarg($tmpDir), escapeshellarg($branch)), $output, $pushExit); - - if ($pushExit !== 0) { - $this->log(" {$repoName}: push failed", 'ERROR'); - return 0; - } - - // Create PR via API - $defaultBranch = $this->getDefaultBranch($org, $repoName); - $pr = $this->adapter->createPullRequest( - $org, $repoName, $title, $branch, $defaultBranch, $body, [ - 'labels' => ['dependencies'], - ] - ); - - return (int) ($pr['number'] ?? 0); - } finally { - if (is_dir($tmpDir)) { - exec(sprintf('rm -rf %s', escapeshellarg($tmpDir))); - } - } - } - - // โ”€โ”€ Auto-merge โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function tryAutoMerge(string $org, string $repoName, int $prNumber): void - { - try { - $this->api->put( - "/repos/{$org}/{$repoName}/pulls/{$prNumber}/merge", - ['Do' => 'squash', 'merge_message_field' => 'chore(deps): auto-merge patch updates'] - ); - $this->autoMerged++; - $this->log(" {$repoName}: PR #{$prNumber} auto-merged", 'INFO'); - } catch (\Exception $e) { - $this->log(" {$repoName}: auto-merge failed โ€” {$e->getMessage()}", 'WARNING'); - } - } - - // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - private function hasExistingDepsPR(string $org, string $repoName): bool - { - try { - $prs = $this->adapter->listPullRequests($org, $repoName, ['state' => 'open']); - foreach ($prs as $pr) { - if (str_starts_with($pr['head']['ref'] ?? '', self::BRANCH_PREFIX)) { - return true; - } - } - } catch (\Exception $e) { - // Ignore โ€” proceed with creating PR - } - return false; - } - - private function getDefaultBranch(string $org, string $repoName): string - { - try { - $repo = $this->api->get("/repos/{$org}/{$repoName}"); - return $repo['default_branch'] ?? 'main'; - } catch (\Exception $e) { - return 'main'; - } - } - - private function isPatchUpdate(string $current, string $latest): bool - { - $cur = explode('.', ltrim($current, 'v')); - $lat = explode('.', ltrim($latest, 'v')); - - if (count($cur) < 3 || count($lat) < 3) { - return false; - } - - // Same major and minor, only patch differs - return $cur[0] === $lat[0] && $cur[1] === $lat[1] && $cur[2] !== $lat[2]; - } - - private function buildPrBody(string $repoName, array $outdated): string - { - $lines = [ - "## Dependency Updates", - "", - "**Repository**: `{$repoName}`", - "**Scanned**: " . date('Y-m-d H:i:s'), - "", - ]; - - foreach ($outdated as $type => $packages) { - $lines[] = "### " . ucfirst($type); - $lines[] = ""; - $lines[] = "| Package | Current | Latest | Type |"; - $lines[] = "|---------|---------|--------|------|"; - - foreach ($packages as $pkg) { - $updateType = $this->isPatchUpdate($pkg['current'], $pkg['latest']) ? 'patch' : 'minor/major'; - $lines[] = "| `{$pkg['name']}` | {$pkg['current']} | {$pkg['latest']} | {$updateType} |"; - } - - $lines[] = ""; - } - - $lines[] = "---"; - $lines[] = "*Auto-generated by `moko deps:update`*"; - - return implode("\n", $lines); - } -} - -$script = new UpdateDependencies('update_dependencies', 'Cross-repo dependency update automation'); -exit($script->execute());