#!/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());