#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * 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/bulk_workflow_push.php * VERSION: 01.00.00 * BRIEF: Push a workflow file to all governed repos via the Gitea Contents API */ declare(strict_types=1); final class BulkWorkflowPush { private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $token = ''; private string $org = ''; private string $workflowFile = ''; private string $destPath = ''; private string $branch = 'main'; private bool $dryRun = false; private int $updated = 0; private int $created = 0; private int $skipped = 0; private int $errors = 0; public function run(): int { $this->parseArgs(); if ($this->token === '') { $this->log('ERROR: --token is required.'); $this->printUsage(); return 1; } if ($this->workflowFile === '') { $this->log('ERROR: --file is required.'); $this->printUsage(); return 1; } if (!file_exists($this->workflowFile)) { $this->log("ERROR: File not found: {$this->workflowFile}"); return 1; } if ($this->org === '') { $this->log('ERROR: --org is required.'); $this->printUsage(); return 1; } if ($this->destPath === '') { $this->destPath = '.mokogitea/workflows/' . basename($this->workflowFile); } $localContent = file_get_contents($this->workflowFile); if ($localContent === false) { $this->log("ERROR: Could not read file: {$this->workflowFile}"); return 1; } $this->log("Pushing: {$this->workflowFile}"); $this->log(" -> {$this->destPath} (branch: {$this->branch})"); $this->log(" -> Org: {$this->org} @ {$this->giteaUrl}"); if ($this->dryRun) { $this->log('[DRY RUN] No changes will be made.'); } $this->log(''); $repos = $this->fetchOrgRepos(); if ($repos === null) { return 1; } $this->log("Found " . count($repos) . " repo(s) in \"{$this->org}\"."); $this->log(''); $this->log(sprintf('%-45s | %s', 'Repo', 'Status')); $this->log(str_repeat('-', 70)); $encodedContent = base64_encode($localContent); foreach ($repos as $repo) { $this->pushToRepo($repo, $encodedContent, $localContent); } $this->log(''); $this->log("Done: {$this->created} created, {$this->updated} updated, " . "{$this->skipped} skipped, {$this->errors} error(s)."); return $this->errors > 0 ? 1 : 0; } private function pushToRepo( string $repoFullName, string $encodedContent, string $localContent ): void { [$owner, $repoName] = explode('/', $repoFullName, 2); $existing = $this->apiRequest( 'GET', "/api/v1/repos/{$owner}/{$repoName}/contents/" . "{$this->destPath}?ref={$this->branch}" ); if ($existing['code'] === 200) { $data = json_decode($existing['body'], true); $remoteSha = $data['sha'] ?? ''; $remoteContent = base64_decode($data['content'] ?? ''); if ($remoteContent === $localContent) { $this->log(sprintf( '%-45s | %s', $repoFullName, 'IDENTICAL (skipped)' )); $this->skipped++; return; } if ($this->dryRun) { $this->log(sprintf( '%-45s | %s', $repoFullName, 'WOULD UPDATE' )); $this->updated++; return; } $payload = json_encode([ 'content' => $encodedContent, 'sha' => $remoteSha, 'message' => "chore: sync {$this->destPath} " . "from moko-platform [skip ci]", 'branch' => $this->branch, ]); $response = $this->apiRequest( 'PUT', "/api/v1/repos/{$owner}/{$repoName}/contents/" . $this->destPath, $payload ); if ($response['code'] === 200) { $this->log(sprintf( '%-45s | %s', $repoFullName, 'UPDATED' )); $this->updated++; } else { $this->log(sprintf( '%-45s | %s', $repoFullName, "ERROR (HTTP {$response['code']})" )); $this->errors++; } } elseif ($existing['code'] === 404) { if ($this->dryRun) { $this->log(sprintf( '%-45s | %s', $repoFullName, 'WOULD CREATE' )); $this->created++; return; } $payload = json_encode([ 'content' => $encodedContent, 'message' => "chore: add {$this->destPath} " . "from moko-platform [skip ci]", 'branch' => $this->branch, ]); $response = $this->apiRequest( 'POST', "/api/v1/repos/{$owner}/{$repoName}/contents/" . $this->destPath, $payload ); if ($response['code'] === 201) { $this->log(sprintf( '%-45s | %s', $repoFullName, 'CREATED' )); $this->created++; } else { $this->log(sprintf( '%-45s | %s', $repoFullName, "ERROR (HTTP {$response['code']})" )); $this->errors++; } } else { $this->log(sprintf( '%-45s | %s', $repoFullName, "ERROR (HTTP {$existing['code']})" )); $this->errors++; } } private function fetchOrgRepos(): ?array { $this->log("Fetching repos from org: {$this->org}"); $page = 1; $repos = []; while (true) { $response = $this->apiRequest( 'GET', "/api/v1/orgs/{$this->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; } private function parseArgs(): void { $args = $_SERVER['argv'] ?? []; $count = count($args); for ($i = 1; $i < $count; $i++) { switch ($args[$i]) { case '--gitea-url': $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); break; case '--token': $this->token = $args[++$i] ?? ''; break; case '--org': $this->org = $args[++$i] ?? ''; break; case '--file': $this->workflowFile = $args[++$i] ?? ''; break; case '--dest': $this->destPath = $args[++$i] ?? ''; break; case '--branch': $this->branch = $args[++$i] ?? 'main'; break; case '--dry-run': $this->dryRun = true; break; case '--help': case '-h': $this->printUsage(); exit(0); default: $this->log("WARNING: Unknown argument: {$args[$i]}"); break; } } } private function printUsage(): void { $this->log( 'Usage: bulk_workflow_push.php ' . '--token --file --org [options]' ); $this->log(''); $this->log( 'Push a workflow file from moko-platform ' . 'to all governed repos.' ); $this->log(''); $this->log('Options:'); $this->log(' --gitea-url Gitea URL ' . '(default: https://git.mokoconsulting.tech)'); $this->log(' --token Gitea API token'); $this->log(' --org Target organization'); $this->log(' --file Local workflow file to push'); $this->log(' --dest Destination path in repos ' . '(default: .mokogitea/workflows/)'); $this->log(' --branch Target branch (default: main)'); $this->log(' --dry-run Show what would be done'); $this->log(' --help, -h Show this help'); } private function apiRequest( string $method, string $endpoint, ?string $body = null ): array { $url = $this->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 {$this->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]; } private function log(string $message): void { fwrite(STDERR, $message . PHP_EOL); } } $app = new BulkWorkflowPush(); exit($app->run());