Files
moko-platform/automation/bulk_joomla_template.php
Jonathan Miller e8da1a30ff
Platform: moko-platform CI / CI Summary (push) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 10s
Generic: Repo Health / Scripts governance (push) Successful in 9s
Generic: Repo Health / Repository health (push) Successful in 17s
Platform: moko-platform CI / CI Summary (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Successful in 8s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 8s
Universal: PR Check / Build RC Package (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Successful in 19s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m16s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m29s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 41s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m40s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m41s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1m40s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 1m20s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 1m24s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 1m24s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m26s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m30s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 12s
fix: PHPStan level 3 → 4 — remove dead code, baseline 41 items
Removed 13 write-only properties and unused code. Remaining 41
baselined items are defensive patterns (null coalesce on API responses,
boolean safety checks) that are intentional.

PHPStan level 4: 0 errors with baseline.

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

938 lines
33 KiB
PHP

#!/usr/bin/env php
<?php
/**
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* This file is part of a Moko Consulting project.
*
* SPDX-License-Identifier: GPL-3.0-or-later
*
* FILE INFORMATION
* DEFGROUP: MokoStandards.Automation
* INGROUP: MokoStandards.Scripts
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
* 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 MokoEnterprise\{
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 MokoStandards 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 = '04.06.10';
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 MokoStandards",
$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<string, string> 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'] = <<<XML
<?xml version="1.0" encoding="utf-8"?>
<extension type="template" client="{$client}" method="upgrade">
<name>{$name}</name>
<creationDate>{$now}</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<version>1.0.0</version>
<description>{$name} — Joomla {$client} template by Moko Consulting</description>
<files>
<filename>index.php</filename>
<filename>component.php</filename>
<filename>error.php</filename>
<filename>offline.php</filename>
<filename>templateDetails.xml</filename>
<folder>html</folder>
<folder>css</folder>
<folder>js</folder>
<folder>images</folder>
<folder>language</folder>
</files>
<media destination="templates/{$client}/{$shortName}" folder="media">
<folder>css</folder>
<folder>js</folder>
<folder>images</folder>
<folder>scss</folder>
</media>
<positions>
<position>topbar</position>
<position>navbar</position>
<position>hero</position>
<position>breadcrumbs</position>
<position>sidebar-left</position>
<position>sidebar-right</position>
<position>main-top</position>
<position>main-bottom</position>
<position>footer</position>
<position>debug</position>
</positions>
<updateservers>
<server type="extension" priority="1" name="{$name} Update Server">
https://git.mokoconsulting.tech/{$org}/{$name}/raw/branch/main/updates.xml
</server>
<server type="extension" priority="2" name="{$name} Update Server">
https://raw.githubusercontent.com/{$org}/{$name}/main/updates.xml
</server>
</updateservers>
<config>
<fields name="params">
<fieldset name="basic">
<field name="logoFile" type="media" label="Logo" />
<field name="siteTitle" type="text" label="Site Title" default="" />
<field name="siteDescription" type="text" label="Site Description" default="" />
<field name="colorScheme" type="list" label="Color Scheme" default="light">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto (system preference)</option>
</field>
</fieldset>
</fields>
</config>
</extension>
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'] = <<<XML
<updates>
<update>
<name>{$name}</name>
<description>{$name} — Moko Consulting Joomla template</description>
<element>tpl_{$shortName}</element>
<type>template</type>
<version>1.0.0</version>
<downloads>
<downloadurl type="full" format="zip">
https://git.mokoconsulting.tech/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip
</downloadurl>
<downloadurl type="full" format="zip">
https://github.com/{$org}/{$name}/releases/download/v1.0.0/{$shortName}.zip
</downloadurl>
</downloads>
<targetplatform name="joomla" version="[56].*"/>
<php_minimum>8.1</php_minimum>
</update>
</updates>
XML;
$files['updates.xml'] = preg_replace('/^\t\t/m', '', $files['updates.xml']);
// src/index.php
$files['src/index.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.TEMPLATE_SHORT_NAME
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
$app = Factory::getApplication();
$wa = $this->getWebAssetManager();
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>" dir="<?php echo $this->direction; ?>">
<head>
<jdoc:include type="metas" />
<jdoc:include type="styles" />
<jdoc:include type="scripts" />
</head>
<body class="site <?php echo $this->direction === 'rtl' ? 'rtl' : 'ltr'; ?>">
<header>
<jdoc:include type="modules" name="topbar" style="none" />
<jdoc:include type="modules" name="navbar" style="none" />
</header>
<jdoc:include type="modules" name="hero" style="none" />
<jdoc:include type="modules" name="breadcrumbs" style="none" />
<main>
<jdoc:include type="modules" name="main-top" style="html5" />
<jdoc:include type="message" />
<jdoc:include type="component" />
<jdoc:include type="modules" name="main-bottom" style="html5" />
</main>
<footer>
<jdoc:include type="modules" name="footer" style="none" />
</footer>
<jdoc:include type="modules" name="debug" style="none" />
</body>
</html>
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'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.error
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
/** @var Joomla\CMS\Document\ErrorDocument $this */
$code = $this->error->getCode();
$message = htmlspecialchars($this->error->getMessage(), ENT_QUOTES, 'UTF-8');
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo $code; ?> — <?php echo $message; ?></title>
</head>
<body>
<div style="max-width:600px;margin:80px auto;text-align:center;font-family:sans-serif">
<h1><?php echo $code; ?></h1>
<p><?php echo $message; ?></p>
<p><a href="<?php echo $this->baseurl; ?>/">Return to homepage</a></p>
</div>
</body>
</html>
PHP;
$files['src/error.php'] = preg_replace('/^\t\t/m', '', $files['src/error.php']);
// src/offline.php
$files['src/offline.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.offline
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
$app = Factory::getApplication();
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo htmlspecialchars($app->get('sitename')); ?> — Maintenance</title>
</head>
<body>
<div style="max-width:600px;margin:80px auto;text-align:center;font-family:sans-serif">
<h1><?php echo htmlspecialchars($app->get('sitename')); ?></h1>
<p><?php echo $app->get('offline_message', 'This site is currently undergoing maintenance. Please check back soon.'); ?></p>
<jdoc:include type="message" />
<form action="<?php echo $this->baseurl; ?>/index.php" method="post">
<input type="text" name="username" placeholder="Username" />
<input type="password" name="password" placeholder="Password" />
<button type="submit">Login</button>
<input type="hidden" name="option" value="com_users" />
<input type="hidden" name="task" value="user.login" />
<?php echo \Joomla\CMS\HTML\HTMLHelper::_('form.token'); ?>
</form>
</div>
</body>
</html>
PHP;
$files['src/offline.php'] = preg_replace('/^\t\t/m', '', $files['src/offline.php']);
// src/component.php
$files['src/component.php'] = <<<'PHP'
<?php
/**
* @package Joomla.Site
* @subpackage Templates.component
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GPL-3.0-or-later
*/
defined('_JEXEC') or die;
?>
<!DOCTYPE html>
<html lang="<?php echo $this->language; ?>">
<head>
<jdoc:include type="head" />
</head>
<body class="contentpane">
<jdoc:include type="message" />
<jdoc:include type="component" />
</body>
</html>
PHP;
$files['src/component.php'] = preg_replace('/^\t\t/m', '', $files['src/component.php']);
// Directory keepfiles
$files['src/html/index.html'] = '<!DOCTYPE html><title></title>';
$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<string, string> 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 <version> 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 <version>.
* 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>([^<]+)<\/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());
}