4cc3f5bee4
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (push) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Blocked by required conditions
Platform: moko-platform CI / Gate 4: Governance (pull_request) Blocked by required conditions
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 6s
Generic: Repo Health / Release configuration (push) Successful in 5s
Generic: Repo Health / Scripts governance (push) Successful in 5s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 6s
Generic: Repo Health / Repository health (push) Successful in 14s
Generic: Repo Health / Repository health (pull_request) Successful in 12s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Failing after 44s
Platform: moko-platform CI / Gate 1: Code Quality (push) Failing after 49s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Has been skipped
Platform: moko-platform CI / Gate 5: Template Integrity (push) Has been skipped
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
- Convert tabs to spaces (3,413 violations) - Fix line endings, trailing whitespace, brace placement - Break lines exceeding 150-char absolute limit - Replace heredoc tab closers with spaces - Fix empty elseif, forbidden function calls - Update phpcs.xml: exclude rules inappropriate for CLI scripts (SideEffects, MissingNamespace, MultipleClasses, HeaderOrder, empty catch blocks) Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
475 lines
17 KiB
PHP
475 lines
17 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
|
|
/**
|
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*
|
|
* FILE INFORMATION
|
|
* DEFGROUP: MokoStandards.Automation
|
|
* INGROUP: MokoStandards
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
* PATH: /automation/enrich_mokostandards_xml.php
|
|
* BRIEF: Enrich XML manifests with repo-specific build and deploy details
|
|
*
|
|
* Enrich XML .mokostandards manifests with repo-specific build, deploy, and script details.
|
|
*
|
|
* Runs AFTER push_mokostandards_xml.php. Clones each repo, inspects its contents,
|
|
* and updates the manifest with discovered build/deploy/scripts config.
|
|
*
|
|
* Usage:
|
|
* php automation/enrich_mokostandards_xml.php [--dry-run] [--repo NAME] [--skip NAME,NAME]
|
|
*
|
|
* 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';
|
|
|
|
use MokoEnterprise\MokoStandardsParser;
|
|
|
|
$giteaUrl = rtrim(getenv('GITEA_URL') ?: 'https://git.mokoconsulting.tech', '/');
|
|
$giteaOrg = getenv('GITEA_ORG') ?: 'MokoConsulting';
|
|
$token = getenv('GA_TOKEN') ?: getenv('GH_TOKEN') ?: '';
|
|
|
|
$dryRun = in_array('--dry-run', $argv, true);
|
|
$repoFilter = null;
|
|
$skipRepos = [];
|
|
foreach ($argv as $i => $arg) {
|
|
if ($arg === '--repo' && isset($argv[$i + 1])) {
|
|
$repoFilter = $argv[$i + 1];
|
|
}
|
|
if ($arg === '--skip' && isset($argv[$i + 1])) {
|
|
$skipRepos = array_map('trim', explode(',', $argv[$i + 1]));
|
|
}
|
|
}
|
|
|
|
$parser = new MokoStandardsParser();
|
|
$tmpBase = sys_get_temp_dir() . '/moko-enrich-' . getmypid();
|
|
|
|
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)];
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function gitCmd(string $workDir, string ...$args): array
|
|
{
|
|
$cmd = 'git';
|
|
foreach ($args as $a) {
|
|
$cmd .= ' ' . escapeshellarg($a);
|
|
}
|
|
return safeExec($cmd, $workDir);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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, '<extension') || str_contains($c, '<install')) {
|
|
$build['entry_point'] = 'src/' . basename($xf);
|
|
break;
|
|
}
|
|
}
|
|
foreach (glob("{$workDir}/src/core/modules/mod*.class.php") ?: [] as $mf) {
|
|
$build['entry_point'] = str_replace("{$workDir}/", '', $mf);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// composer.json
|
|
if (file_exists("{$workDir}/composer.json")) {
|
|
$composer = json_decode(file_get_contents("{$workDir}/composer.json"), true) ?: [];
|
|
$phpReq = $composer['require']['php'] ?? null;
|
|
if ($phpReq) {
|
|
$build['runtime'] = "php:{$phpReq}";
|
|
}
|
|
|
|
$deps = [];
|
|
foreach (['joomla/cms', 'joomla/framework', 'dolibarr/dolibarr'] as $pd) {
|
|
if (isset($composer['require'][$pd])) {
|
|
$deps[] = ['name' => $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;
|
|
}
|
|
|
|
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 = MokoStandardsParser::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'])) {
|
|
$build = $dom->createElementNS($ns, 'build');
|
|
$b = $enrichment['build'];
|
|
foreach (['language', 'runtime'] as $f) {
|
|
if (isset($b[$f])) {
|
|
$build->appendChild($dom->createElementNS($ns, $f, htmlspecialchars($b[$f], ENT_XML1)));
|
|
}
|
|
}
|
|
if (isset($b['package_type'])) {
|
|
$build->appendChild($dom->createElementNS($ns, 'package-type', htmlspecialchars($b['package_type'], ENT_XML1)));
|
|
}
|
|
if (isset($b['entry_point'])) {
|
|
$build->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)));
|
|
}
|
|
}
|
|
$build->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);
|
|
}
|
|
$build->appendChild($deps);
|
|
}
|
|
$root->appendChild($build);
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// ── Main ─────────────────────────────────────────────────────────────────
|
|
echo "=== MokoStandards XML Manifest Enrichment ===\n";
|
|
echo "Mode: " . ($dryRun ? "DRY RUN" : "LIVE") . "\n";
|
|
if (!empty($skipRepos)) {
|
|
echo "Skipping: " . implode(', ', $skipRepos) . "\n";
|
|
}
|
|
echo "\n";
|
|
|
|
if (empty($token)) {
|
|
fprintf(STDERR, "ERROR: GA_TOKEN required\n");
|
|
exit(1);
|
|
}
|
|
|
|
$repos = 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] = 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/.mokostandards";
|
|
if (!file_exists($manifestPath) || !str_contains(file_get_contents($manifestPath), '<mokostandards')) {
|
|
echo "SKIP (no XML manifest)\n";
|
|
$stats['skipped']++;
|
|
rmTree($workDir);
|
|
continue;
|
|
}
|
|
|
|
$existingXml = file_get_contents($manifestPath);
|
|
$platform = $parser->extractPlatform($existingXml) ?? 'default-repository';
|
|
$enrichment = inspectRepo($workDir, $platform);
|
|
|
|
if (!isset($enrichment['build'])) {
|
|
$enrichment['build'] = [];
|
|
}
|
|
$enrichment['build']['language'] = $enrichment['build']['language'] ?? $repo['language'] ?? MokoStandardsParser::platformLanguage($platform);
|
|
$enrichment['build']['package_type'] = $enrichment['build']['package_type'] ?? MokoStandardsParser::platformPackageType($platform);
|
|
|
|
$enrichedXml = enrichManifestXml($existingXml, $enrichment);
|
|
$dc = count($enrichment['deploy'] ?? []);
|
|
$sc = count($enrichment['scripts'] ?? []);
|
|
$details = "deploy={$dc} scripts={$sc}";
|
|
|
|
if ($dryRun) {
|
|
echo "WOULD ENRICH [{$details}]\n";
|
|
$stats['enriched']++;
|
|
rmTree($workDir);
|
|
continue;
|
|
}
|
|
|
|
file_put_contents($manifestPath, $enrichedXml);
|
|
gitCmd($workDir, 'config', 'user.name', 'gitea-actions[bot]');
|
|
gitCmd($workDir, 'config', 'user.email', 'gitea-actions[bot]@git.mokoconsulting.tech');
|
|
gitCmd($workDir, 'add', '.mokogitea/.mokostandards');
|
|
|
|
[$cr, $co] = gitCmd($workDir, 'commit', '-m', "chore: enrich .mokostandards with build/deploy/scripts\n\nAuto-detected: {$details}");
|
|
if ($cr !== 0) {
|
|
echo "SKIP (no diff)\n";
|
|
$stats['skipped']++;
|
|
rmTree($workDir);
|
|
continue;
|
|
}
|
|
|
|
[$pr] = gitCmd($workDir, 'push', 'origin', $defaultBranch);
|
|
if ($pr !== 0) {
|
|
echo "FAIL (push)\n";
|
|
$stats['failed']++;
|
|
} else {
|
|
echo "ENRICHED [{$details}]\n";
|
|
$stats['enriched']++;
|
|
}
|
|
|
|
rmTree($workDir);
|
|
}
|
|
|
|
@rmdir($tmpBase);
|
|
echo "\n=== Summary ===\nEnriched: {$stats['enriched']}\nSkipped: {$stats['skipped']}\nFailed: {$stats['failed']}\n";
|