From 2e5446ff5e761102e08ef0234b9f794355b0ffcd Mon Sep 17 00:00:00 2001 From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech> Date: Sun, 7 Jun 2026 17:27:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20workflow=5Fsync.php=20=E2=80=94?= =?UTF-8?q?=20cascading=20sync=20based=20on=20manifest.platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/workflow_sync.php | 646 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 646 insertions(+) create mode 100644 cli/workflow_sync.php diff --git a/cli/workflow_sync.php b/cli/workflow_sync.php new file mode 100644 index 0000000..a8b3b68 --- /dev/null +++ b/cli/workflow_sync.php @@ -0,0 +1,646 @@ +#!/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: 01.00.00 + * 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());