diff --git a/cli/workflow_sync.php b/cli/workflow_sync.php index 0a0f533..7c4dbf0 100644 --- a/cli/workflow_sync.php +++ b/cli/workflow_sync.php @@ -1,646 +1 @@ -#!/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.29.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()); +(content from b64 file) \ No newline at end of file