From f942615a121f142c53e0eb0c2c2df4dcac379a88 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech> Date: Sat, 20 Jun 2026 17:30:34 +0000 Subject: [PATCH] fix: add branch+PR fallback for protected repos, rename moko-platform to mokocli --- cli/workflow_sync.php | 1429 ++++++++++++++++++++++------------------- 1 file changed, 762 insertions(+), 667 deletions(-) diff --git a/cli/workflow_sync.php b/cli/workflow_sync.php index 0c11c2c..4ecc7cd 100644 --- a/cli/workflow_sync.php +++ b/cli/workflow_sync.php @@ -1,667 +1,762 @@ -#!/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.01 - * 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'; - - /** - * Workflows to exclude per platform during sync. - * Key = platform name (matching PLATFORM_TEMPLATES keys), Value = array of workflow filenames to skip. - */ - private const PLATFORM_EXCLUDES = [ - 'joomla' => ['deploy-manual.yml'], - ]; - - 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']; - // Skip platform-excluded workflows - $templatePlatform = array_search($templateRepo, self::PLATFORM_TEMPLATES, true); - if ($templatePlatform !== false && in_array($filename, self::PLATFORM_EXCLUDES[$templatePlatform] ?? [], true)) { - fprintf(STDERR, "%-45s | %s\n", "{$templateRepo}/{$filename}", 'EXCLUDED (platform)'); - $this->skipped++; - continue; - } - $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']; - // Skip platform-excluded workflows - if (in_array($filename, self::PLATFORM_EXCLUDES[$platform] ?? [], true)) { - fprintf(STDERR, "%-45s | %s\n", $label, 'EXCLUDED (platform)'); - $this->skipped++; - continue; - } - $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()); +#!/usr/bin/env php + + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: mokocli.CLI + * INGROUP: mokocli + * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli + * PATH: /cli/workflow_sync.php + * VERSION: 09.29.01 + * 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'; + + /** + * Workflows to exclude per platform during sync. + * Key = platform name (matching PLATFORM_TEMPLATES keys), Value = array of workflow filenames to skip. + */ + private const PLATFORM_EXCLUDES = [ + 'joomla' => ['deploy-manual.yml'], + ]; + + 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']; + // Skip platform-excluded workflows + $templatePlatform = array_search($templateRepo, self::PLATFORM_TEMPLATES, true); + if ($templatePlatform !== false && in_array($filename, self::PLATFORM_EXCLUDES[$templatePlatform] ?? [], true)) { + fprintf(STDERR, "%-45s | %s\n", "{$templateRepo}/{$filename}", 'EXCLUDED (platform)'); + $this->skipped++; + continue; + } + $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']; + // Skip platform-excluded workflows + if (in_array($filename, self::PLATFORM_EXCLUDES[$platform] ?? [], true)) { + fprintf(STDERR, "%-45s | %s\n", $label, 'EXCLUDED (platform)'); + $this->skipped++; + continue; + } + $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++; + } elseif ($response['code'] === 403) { + $this->pushViaPR($giteaUrl, $token, $org, $repoName, $encodedContent, $remoteSha, $destPath, $branch, $commitMsg, $label); + } 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++; + } elseif ($response['code'] === 403) { + $this->pushViaPR($giteaUrl, $token, $org, $repoName, $encodedContent, '', $destPath, $branch, $commitMsg, $label); + } 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++; + } + } + + /** + * Fallback: push via chore branch + PR when direct push is blocked (403). + */ + private function pushViaPR( + string $giteaUrl, + string $token, + string $org, + string $repoName, + string $encodedContent, + string $remoteSha, + string $destPath, + string $targetBranch, + string $commitMsg, + string $label + ): void { + $repoFullName = "{$org}/{$repoName}"; + $choreBranch = 'chore/workflow-sync'; + $apiBase = "/api/v1/repos/{$org}/{$repoName}"; + + // 1. Create chore branch from target + $branchPayload = json_encode([ + 'new_branch_name' => $choreBranch, + 'old_branch_name' => $targetBranch, + ]); + $branchResp = $this->apiRequest($giteaUrl, $token, 'POST', "{$apiBase}/branches", $branchPayload); + if ($branchResp['code'] !== 201 && $branchResp['code'] !== 409) { + fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (branch create HTTP {$branchResp['code']})"); + $this->errors++; + return; + } + + // If branch already exists, get current SHA on that branch + if ($branchResp['code'] === 409 || $remoteSha === '') { + $existing = $this->apiRequest($giteaUrl, $token, 'GET', + "{$apiBase}/contents/{$destPath}?ref={$choreBranch}"); + if ($existing['code'] === 200) { + $data = json_decode($existing['body'], true); + $remoteSha = $data['sha'] ?? ''; + } + } + + // 2. Push file to chore branch + $filePayload = ['content' => $encodedContent, 'message' => $commitMsg, 'branch' => $choreBranch]; + if ($remoteSha !== '') { + $filePayload['sha'] = $remoteSha; + $method = 'PUT'; + } else { + $method = 'POST'; + } + $fileResp = $this->apiRequest($giteaUrl, $token, $method, + "{$apiBase}/contents/{$destPath}", json_encode($filePayload)); + if ($fileResp['code'] !== 200 && $fileResp['code'] !== 201 && $fileResp['code'] !== 422) { + fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (file push HTTP {$fileResp['code']})"); + $this->errors++; + return; + } + + // 3. Create PR + $prPayload = json_encode([ + 'title' => "chore: sync workflows from mokocli", + 'body' => "Automated workflow sync.", + 'head' => $choreBranch, + 'base' => $targetBranch, + ]); + $prResp = $this->apiRequest($giteaUrl, $token, 'POST', "{$apiBase}/pulls", $prPayload); + + if ($prResp['code'] === 201) { + $prData = json_decode($prResp['body'], true); + $prNumber = $prData['number'] ?? '?'; + + // 4. Auto-merge + $mergePayload = json_encode(['Do' => 'merge', 'merge_message_field' => $commitMsg]); + $mergeResp = $this->apiRequest($giteaUrl, $token, 'POST', + "{$apiBase}/pulls/{$prNumber}/merge", $mergePayload); + + if ($mergeResp['code'] === 200 || $mergeResp['code'] === 204) { + fprintf(STDERR, "%-45s | %s\n", $label, "UPDATED (via PR #{$prNumber}, merged)"); + $this->updated++; + } else { + fprintf(STDERR, "%-45s | %s\n", $label, "PR #{$prNumber} created (merge pending)"); + $this->updated++; + } + } elseif ($prResp['code'] === 409 || $prResp['code'] === 422) { + fprintf(STDERR, "%-45s | %s\n", $label, 'PR already exists'); + $this->skipped++; + } else { + fprintf(STDERR, "%-45s | %s\n", $label, "ERROR (PR create HTTP {$prResp['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());