#!/usr/bin/env php * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: moko-platform.CLI * INGROUP: moko-platform * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /cli/workflow_sync.php * VERSION: 09.25.02 * BRIEF: Sync workflows from Generic → platform templates → live repos based on manifest.platform */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class WorkflowSyncCli extends CliFramework { private const PLATFORM_TEMPLATES = [ 'joomla' => 'Template-Joomla', 'dolibarr' => 'Template-Dolibarr', 'go' => 'Template-Go', 'mcp' => 'Template-MCP', 'platform' => 'Template-Generic', 'generic' => 'Template-Generic', ]; private const DEFAULT_TEMPLATE = 'Template-Generic'; private const GENERIC_TEMPLATE = 'Template-Generic'; private int $updated = 0; private int $created = 0; private int $skipped = 0; private int $errors = 0; protected function configure(): void { $this->setDescription('Sync workflows from Generic → platform templates → live repos based on manifest.platform'); $this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech'); $this->addArgument('--token', 'Gitea API token', ''); $this->addArgument('--org', 'Target organization', ''); $this->addArgument('--branch', 'Target branch (default: main)', 'main'); $this->addArgument('--phase', 'Phase to run: all, templates, repos (default: all)', 'all'); $this->addArgument('--platform-filter', 'Only sync repos matching this platform', ''); } protected function run(): int { $giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); $token = $this->getArgument('--token'); $org = $this->getArgument('--org'); $branch = $this->getArgument('--branch'); $phase = $this->getArgument('--phase'); $platformFilter = $this->getArgument('--platform-filter'); if ($token === '') { $this->log('ERROR', '--token is required.'); return 1; } if ($org === '') { $this->log('ERROR', '--org is required.'); return 1; } if (!in_array($phase, ['all', 'templates', 'repos'], true)) { $this->log('ERROR', "--phase must be one of: all, templates, repos (got: {$phase})"); return 1; } $this->log('INFO', "Workflow Sync — org: {$org}, branch: {$branch}, phase: {$phase}"); if ($platformFilter !== '') { $this->log('INFO', "Platform filter: {$platformFilter}"); } if ($this->dryRun) { $this->log('INFO', '[DRY RUN] No changes will be made.'); } echo "\n"; // Phase 1: Sync Generic → Platform Templates if ($phase === 'all' || $phase === 'templates') { $result = $this->syncGenericToTemplates($giteaUrl, $token, $org, $branch, $platformFilter); if ($result !== 0) { return $result; } } // Phase 2: Sync Platform Templates → Live Repos if ($phase === 'all' || $phase === 'repos') { $result = $this->syncTemplatesToRepos($giteaUrl, $token, $org, $branch, $platformFilter); if ($result !== 0) { return $result; } } echo "\n"; $this->log('INFO', "Done: {$this->created} created, {$this->updated} updated, " . "{$this->skipped} skipped, {$this->errors} error(s)."); return $this->errors > 0 ? 1 : 0; } /** * Phase 1: Push all Generic workflows to each platform template repo. * Skips platform-specific overrides (files that exist in the platform template but NOT in Generic). */ private function syncGenericToTemplates( string $giteaUrl, string $token, string $org, string $branch, string $platformFilter ): int { $this->log('INFO', '=== Phase 1: Sync Generic → Platform Templates ==='); echo "\n"; // Get all workflow files from Template-Generic $genericWorkflows = $this->listWorkflows($giteaUrl, $token, $org, self::GENERIC_TEMPLATE, $branch); if ($genericWorkflows === null) { $this->log('ERROR', 'Could not list workflows from ' . self::GENERIC_TEMPLATE); return 1; } if (count($genericWorkflows) === 0) { $this->log('WARN', 'No workflows found in ' . self::GENERIC_TEMPLATE); return 0; } $this->log('INFO', 'Found ' . count($genericWorkflows) . ' workflow(s) in ' . self::GENERIC_TEMPLATE); echo "\n"; // Get unique platform templates (exclude Generic itself) $platformTemplates = array_unique(array_filter( array_values(self::PLATFORM_TEMPLATES), fn(string $t) => $t !== self::GENERIC_TEMPLATE )); // If platform-filter is set, only sync to the matching template if ($platformFilter !== '') { $targetTemplate = self::PLATFORM_TEMPLATES[$platformFilter] ?? null; if ($targetTemplate === null || $targetTemplate === self::GENERIC_TEMPLATE) { $this->log('INFO', "Platform filter '{$platformFilter}' does not map to a non-generic template, skipping Phase 1."); return 0; } $platformTemplates = [$targetTemplate]; } fprintf(STDERR, "%-45s | %s\n", 'Template / File', 'Status'); fprintf(STDERR, "%s\n", str_repeat('-', 70)); foreach ($platformTemplates as $templateRepo) { foreach ($genericWorkflows as $workflow) { $filename = $workflow['name']; $destPath = '.mokogitea/workflows/' . $filename; $label = "{$templateRepo}/{$filename}"; // Get file content from Generic $sourceContent = $this->getFileContent( $giteaUrl, $token, $org, self::GENERIC_TEMPLATE, $destPath, $branch ); if ($sourceContent === null) { fprintf(STDERR, "%-45s | %s\n", $label, 'ERROR (read source)'); $this->errors++; continue; } $commitMsg = "chore: sync {$filename} from " . self::GENERIC_TEMPLATE . " [skip ci]"; $this->pushFile( $giteaUrl, $token, $org, $templateRepo, $destPath, $sourceContent, $branch, $commitMsg, $label ); } } echo "\n"; return 0; } /** * Phase 2: Sync platform template workflows to live repos based on manifest.platform. */ private function syncTemplatesToRepos( string $giteaUrl, string $token, string $org, string $branch, string $platformFilter ): int { $this->log('INFO', '=== Phase 2: Sync Platform Templates → Live Repos ==='); echo "\n"; $repos = $this->fetchOrgRepos($giteaUrl, $token, $org); if ($repos === null) { return 1; } $this->log('INFO', 'Found ' . count($repos) . " repo(s) in \"{$org}\"."); echo "\n"; fprintf(STDERR, "%-45s | %s\n", 'Repo / File', 'Status'); fprintf(STDERR, "%s\n", str_repeat('-', 70)); // Cache template workflows to avoid repeated API calls $templateWorkflowCache = []; foreach ($repos as $repoFullName) { [, $repoName] = explode('/', $repoFullName, 2); // Skip template repos if (str_starts_with($repoName, 'Template-')) { continue; } // Read manifest.platform $platform = $this->getRepoPlatform($giteaUrl, $token, $org, $repoName, $branch); // Apply platform filter if ($platformFilter !== '' && $platform !== $platformFilter) { continue; } // Resolve template $templateRepo = self::PLATFORM_TEMPLATES[$platform] ?? self::DEFAULT_TEMPLATE; // Get workflows from the template (cached) if (!isset($templateWorkflowCache[$templateRepo])) { $workflows = $this->listWorkflows($giteaUrl, $token, $org, $templateRepo, $branch); if ($workflows === null) { $this->log('WARN', "Could not list workflows from {$templateRepo}, falling back to " . self::GENERIC_TEMPLATE); $workflows = $this->listWorkflows($giteaUrl, $token, $org, self::GENERIC_TEMPLATE, $branch); } $templateWorkflowCache[$templateRepo] = $workflows ?? []; } $workflows = $templateWorkflowCache[$templateRepo]; if (count($workflows) === 0) { continue; } foreach ($workflows as $workflow) { $filename = $workflow['name']; $destPath = '.mokogitea/workflows/' . $filename; $label = "{$repoFullName}/{$filename}"; // Get source content from template $sourceContent = $this->getFileContent( $giteaUrl, $token, $org, $templateRepo, $destPath, $branch ); if ($sourceContent === null) { fprintf(STDERR, "%-45s | %s\n", $label, 'ERROR (read source)'); $this->errors++; continue; } $commitMsg = "chore: sync {$filename} from {$templateRepo} [skip ci]"; $this->pushFile( $giteaUrl, $token, $org, $repoName, $destPath, $sourceContent, $branch, $commitMsg, $label ); } } echo "\n"; return 0; } /** * Push a file to a repo — create or update, skip if identical. */ private function pushFile( string $giteaUrl, string $token, string $org, string $repoName, string $destPath, string $localContent, string $branch, string $commitMsg, string $label ): void { $existing = $this->apiRequest( $giteaUrl, $token, 'GET', "/api/v1/repos/{$org}/{$repoName}/contents/" . "{$destPath}?ref={$branch}" ); $encodedContent = base64_encode($localContent); if ($existing['code'] === 200) { $data = json_decode($existing['body'], true); $remoteSha = $data['sha'] ?? ''; $remoteContent = base64_decode($data['content'] ?? ''); if ($remoteContent === $localContent) { fprintf(STDERR, "%-45s | %s\n", $label, 'IDENTICAL (skipped)'); $this->skipped++; return; } if ($this->dryRun) { fprintf(STDERR, "%-45s | %s\n", $label, 'WOULD UPDATE'); $this->updated++; return; } $payload = json_encode([ 'content' => $encodedContent, 'sha' => $remoteSha, 'message' => $commitMsg, 'branch' => $branch, ]); $response = $this->apiRequest( $giteaUrl, $token, 'PUT', "/api/v1/repos/{$org}/{$repoName}/contents/" . $destPath, $payload ); if ($response['code'] === 200) { fprintf(STDERR, "%-45s | %s\n", $label, 'UPDATED'); $this->updated++; } else { fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (HTTP {$response['code']})"); $this->errors++; } } elseif ($existing['code'] === 404) { if ($this->dryRun) { fprintf(STDERR, "%-45s | %s\n", $label, 'WOULD CREATE'); $this->created++; return; } $payload = json_encode([ 'content' => $encodedContent, 'message' => $commitMsg, 'branch' => $branch, ]); $response = $this->apiRequest( $giteaUrl, $token, 'POST', "/api/v1/repos/{$org}/{$repoName}/contents/" . $destPath, $payload ); if ($response['code'] === 201) { fprintf(STDERR, "%-45s | %s\n", $label, 'CREATED'); $this->created++; } else { fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (HTTP {$response['code']})"); $this->errors++; } } else { fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (HTTP {$existing['code']})"); $this->errors++; } } /** * List workflow files in a repo's .mokogitea/workflows/ directory. */ private function listWorkflows( string $giteaUrl, string $token, string $org, string $repoName, string $branch ): ?array { $response = $this->apiRequest( $giteaUrl, $token, 'GET', "/api/v1/repos/{$org}/{$repoName}/contents/.mokogitea/workflows?ref={$branch}" ); if ($response['code'] !== 200) { return null; } $data = json_decode($response['body'], true); if (!is_array($data)) { return null; } // Filter to only files (not directories) return array_values(array_filter($data, fn($item) => ($item['type'] ?? '') === 'file')); } /** * Get file content from a repo as a raw string. */ private function getFileContent( string $giteaUrl, string $token, string $org, string $repoName, string $filePath, string $branch ): ?string { $response = $this->apiRequest( $giteaUrl, $token, 'GET', "/api/v1/repos/{$org}/{$repoName}/contents/{$filePath}?ref={$branch}" ); if ($response['code'] !== 200) { return null; } $data = json_decode($response['body'], true); if (!is_array($data) || !isset($data['content'])) { return null; } return base64_decode($data['content']); } /** * Read a repo's manifest.xml and extract the platform value. * Returns 'generic' if the manifest is missing or has no platform field. */ private function getRepoPlatform( string $giteaUrl, string $token, string $org, string $repoName, string $branch ): string { $response = $this->apiRequest( $giteaUrl, $token, 'GET', "/api/v1/repos/{$org}/{$repoName}/contents/.mokogitea/manifest.xml?ref={$branch}" ); if ($response['code'] !== 200) { return 'generic'; } $data = json_decode($response['body'], true); if (!is_array($data) || !isset($data['content'])) { return 'generic'; } $xmlContent = base64_decode($data['content']); if ($xmlContent === false || $xmlContent === '') { return 'generic'; } // Suppress XML warnings for malformed manifests $previous = libxml_use_internal_errors(true); $xml = simplexml_load_string($xmlContent); libxml_use_internal_errors($previous); if ($xml === false) { return 'generic'; } // Try (standard location) $platform = ''; // Register namespace if present $namespaces = $xml->getNamespaces(true); if (!empty($namespaces)) { $ns = reset($namespaces); $xml->registerXPathNamespace('mp', $ns); $nodes = $xml->xpath('//mp:governance/mp:platform'); if (!empty($nodes)) { $platform = trim((string) $nodes[0]); } // Fallback: if ($platform === '') { $nodes = $xml->xpath('//mp:identity/mp:platform'); if (!empty($nodes)) { $platform = trim((string) $nodes[0]); } } // Fallback: top-level if ($platform === '') { $nodes = $xml->xpath('//mp:platform'); if (!empty($nodes)) { $platform = trim((string) $nodes[0]); } } } else { // No namespace if (isset($xml->governance->platform)) { $platform = trim((string) $xml->governance->platform); } elseif (isset($xml->identity->platform)) { $platform = trim((string) $xml->identity->platform); } elseif (isset($xml->platform)) { $platform = trim((string) $xml->platform); } } if ($platform === '') { return 'generic'; } return strtolower($platform); } /** * Fetch all non-archived repos in an org (paginated). */ private function fetchOrgRepos(string $giteaUrl, string $token, string $org): ?array { $this->log('INFO', "Fetching repos from org: {$org}"); $page = 1; $repos = []; while (true) { $response = $this->apiRequest( $giteaUrl, $token, 'GET', "/api/v1/orgs/{$org}/repos?" . "limit=50&page={$page}" ); if ($response['code'] < 200 || $response['code'] >= 300) { if ($page === 1) { $this->log('ERROR', "Could not fetch repos " . "(HTTP {$response['code']})."); return null; } break; } $data = json_decode($response['body'], true); if (!is_array($data) || count($data) === 0) { break; } foreach ($data as $repo) { if (!empty($repo['archived'])) { continue; } $fullName = $repo['full_name'] ?? ''; if ($fullName !== '') { $repos[] = $fullName; } } $page++; } return $repos; } /** * Make an HTTP request to the Gitea API. */ private function apiRequest( string $giteaUrl, string $token, string $method, string $endpoint, ?string $body = null ): array { $url = $giteaUrl . $endpoint; $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Accept: application/json', "Authorization: token {$token}", ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $responseBody = curl_exec($ch); $httpCode = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE ); if (curl_errno($ch)) { $error = curl_error($ch); curl_close($ch); return [ 'code' => 0, 'body' => "cURL error: {$error}", ]; } curl_close($ch); return ['code' => $httpCode, 'body' => $responseBody]; } } $app = new WorkflowSyncCli(); exit($app->execute());