#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: mokocli.CLI * INGROUP: mokocli * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * PATH: /cli/bulk_workflow_push.php * VERSION: 09.29.01 * BRIEF: Push a workflow file to all governed repos via the Gitea Contents API */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class BulkWorkflowPushCli extends CliFramework { private int $updated = 0; private int $created = 0; private int $skipped = 0; private int $errors = 0; protected function configure(): void { $this->setDescription('Push a workflow file to all governed repos via the Gitea Contents API'); $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('--file', 'Local workflow file to push', ''); $this->addArgument('--dest', 'Destination path in repos (default: .mokogitea/workflows/)', ''); $this->addArgument('--branch', 'Target branch (default: main)', 'main'); } protected function run(): int { $giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); $token = $this->getArgument('--token'); $org = $this->getArgument('--org'); $workflowFile = $this->getArgument('--file'); $destPath = $this->getArgument('--dest'); $branch = $this->getArgument('--branch'); if ($token === '') { $this->log('ERROR', '--token is required.'); return 1; } if ($workflowFile === '') { $this->log('ERROR', '--file is required.'); return 1; } if (!file_exists($workflowFile)) { $this->log('ERROR', "File not found: {$workflowFile}"); return 1; } if ($org === '') { $this->log('ERROR', '--org is required.'); return 1; } if ($destPath === '') { $destPath = '.mokogitea/workflows/' . basename($workflowFile); } $localContent = file_get_contents($workflowFile); if ($localContent === false) { $this->log('ERROR', "Could not read file: {$workflowFile}"); return 1; } $this->log('INFO', "Pushing: {$workflowFile}"); $this->log('INFO', " -> {$destPath} (branch: {$branch})"); $this->log('INFO', " -> Org: {$org} @ {$giteaUrl}"); if ($this->dryRun) { $this->log('INFO', '[DRY RUN] No changes will be made.'); } 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', 'Status'); fprintf(STDERR, "%s\n", str_repeat('-', 70)); $encodedContent = base64_encode($localContent); foreach ($repos as $repo) { $this->pushToRepo($giteaUrl, $token, $repo, $encodedContent, $localContent, $destPath, $branch); } 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; } private function pushToRepo( string $giteaUrl, string $token, string $repoFullName, string $encodedContent, string $localContent, string $destPath, string $branch ): void { [$owner, $repoName] = explode('/', $repoFullName, 2); $existing = $this->apiRequest( $giteaUrl, $token, 'GET', "/api/v1/repos/{$owner}/{$repoName}/contents/" . "{$destPath}?ref={$branch}" ); 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", $repoFullName, 'IDENTICAL (skipped)'); $this->skipped++; return; } if ($this->dryRun) { fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD UPDATE'); $this->updated++; return; } $payload = json_encode([ 'content' => $encodedContent, 'sha' => $remoteSha, 'message' => "chore: sync {$destPath} " . "from mokocli [skip ci]", 'branch' => $branch, ]); $response = $this->apiRequest( $giteaUrl, $token, 'PUT', "/api/v1/repos/{$owner}/{$repoName}/contents/" . $destPath, $payload ); if ($response['code'] === 200) { fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'UPDATED'); $this->updated++; } elseif ($response['code'] === 403) { // Branch protection — fall back to chore branch + PR $this->pushViaPR($giteaUrl, $token, $owner, $repoName, $encodedContent, $remoteSha, $destPath, $branch); } else { fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})"); $this->errors++; } } elseif ($existing['code'] === 404) { if ($this->dryRun) { fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'WOULD CREATE'); $this->created++; return; } $payload = json_encode([ 'content' => $encodedContent, 'message' => "chore: add {$destPath} " . "from mokocli [skip ci]", 'branch' => $branch, ]); $response = $this->apiRequest( $giteaUrl, $token, 'POST', "/api/v1/repos/{$owner}/{$repoName}/contents/" . $destPath, $payload ); if ($response['code'] === 201) { fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'CREATED'); $this->created++; } elseif ($response['code'] === 403) { $this->pushViaPR($giteaUrl, $token, $owner, $repoName, $encodedContent, '', $destPath, $branch); } else { fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (HTTP {$response['code']})"); $this->errors++; } } else { fprintf(STDERR, "%-45s | %s\n", $repoFullName, "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 $owner, string $repoName, string $encodedContent, string $remoteSha, string $destPath, string $targetBranch ): void { $repoFullName = "{$owner}/{$repoName}"; $choreBranch = 'chore/workflow-sync'; $commitMsg = "chore: sync {$destPath} from mokocli [skip ci]"; $apiBase = "/api/v1/repos/{$owner}/{$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", $repoFullName, "ERROR (branch create HTTP {$branchResp['code']})"); $this->errors++; return; } // If branch already exists (409), get the current SHA of the file 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) { // 422 = file unchanged, still create PR if branch is new if ($fileResp['code'] !== 422) { fprintf(STDERR, "%-45s | %s\n", $repoFullName, "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 via bulk_workflow_push.", '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 the PR $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", $repoFullName, "UPDATED (via PR #{$prNumber}, merged)"); $this->updated++; } else { fprintf(STDERR, "%-45s | %s\n", $repoFullName, "PR #{$prNumber} created (merge HTTP {$mergeResp['code']})"); $this->updated++; } } elseif ($prResp['code'] === 409 || $prResp['code'] === 422) { fprintf(STDERR, "%-45s | %s\n", $repoFullName, 'PR already exists'); $this->skipped++; } else { fprintf(STDERR, "%-45s | %s\n", $repoFullName, "ERROR (PR create HTTP {$prResp['code']})"); $this->errors++; } } 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; } 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 BulkWorkflowPushCli(); exit($app->execute());