#!/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_trigger.php * VERSION: 09.22.00 * BRIEF: Trigger a workflow across multiple repos at once */ declare(strict_types=1); require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\CliFramework; class BulkWorkflowTriggerCli extends CliFramework { private string $giteaUrl = 'https://git.mokoconsulting.tech'; private string $token = ''; private string $reposFile = ''; private string $org = ''; private string $workflow = ''; private string $ref = 'main'; private string $inputs = ''; protected function configure(): void { $this->setDescription('Trigger a workflow across multiple repos at once'); $this->addArgument('--gitea-url', 'Gitea URL (default: https://git.mokoconsulting.tech)', 'https://git.mokoconsulting.tech'); $this->addArgument('--token', 'Gitea API token', ''); $this->addArgument('--repos', 'File with newline-separated owner/repo list', ''); $this->addArgument('--org', 'Trigger on all repos in an org', ''); $this->addArgument('--workflow', 'Workflow file (e.g., "sync-servers.yml")', ''); $this->addArgument('--ref', 'Branch ref (default: "main")', 'main'); $this->addArgument('--inputs', 'Workflow inputs as JSON string', ''); } protected function run(): int { $this->giteaUrl = rtrim($this->getArgument('--gitea-url'), '/'); $this->token = $this->getArgument('--token'); $this->reposFile = $this->getArgument('--repos'); $this->org = $this->getArgument('--org'); $this->workflow = $this->getArgument('--workflow'); $this->ref = $this->getArgument('--ref'); $this->inputs = $this->getArgument('--inputs'); if ($this->token === '') { $this->log('ERROR', '--token is required.'); return 1; } if ($this->workflow === '') { $this->log('ERROR', '--workflow is required.'); return 1; } if ($this->reposFile === '' && $this->org === '') { $this->log('ERROR', 'Either --repos or --org is required.'); return 1; } // Build repo list $repos = $this->buildRepoList(); if ($repos === null || count($repos) === 0) { $this->log('ERROR', 'No repos found to process.'); return 1; } $this->log('INFO', "Triggering workflow \"{$this->workflow}\" on ref \"{$this->ref}\" across " . count($repos) . " repo(s)."); $this->log('INFO', "Gitea URL: {$this->giteaUrl}"); if ($this->dryRun) { $this->log('INFO', '[DRY RUN] No requests will be sent.'); } $this->log('INFO', ''); // Parse inputs $inputsDecoded = null; if ($this->inputs !== '') { $inputsDecoded = json_decode($this->inputs, true); if (!is_array($inputsDecoded)) { $this->log('ERROR', '--inputs must be valid JSON.'); return 1; } } // Print header $this->log('INFO', sprintf('%-40s | %s', 'Repo', 'Status')); $this->log('INFO', str_repeat('-', 60)); $failCount = 0; foreach ($repos as $repo) { $repo = trim($repo); if ($repo === '' || strpos($repo, '/') === false) { continue; } [$owner, $repoName] = explode('/', $repo, 2); if ($this->dryRun) { $this->log('INFO', sprintf('%-40s | %s', $repo, 'DRY RUN (skipped)')); continue; } $payload = ['ref' => $this->ref]; if ($inputsDecoded !== null) { $payload['inputs'] = $inputsDecoded; } $response = $this->apiRequest( 'POST', "/api/v1/repos/{$owner}/{$repoName}/actions/workflows/{$this->workflow}/dispatches", json_encode($payload) ); if ($response['code'] >= 200 && $response['code'] < 300) { $status = 'TRIGGERED'; } elseif ($response['code'] === 404) { $status = 'FAILED (not found)'; $failCount++; } elseif ($response['code'] === 422) { $status = 'SKIPPED (unprocessable)'; } else { $status = "FAILED (HTTP {$response['code']})"; $failCount++; } $this->log('INFO', sprintf('%-40s | %s', $repo, $status)); } $this->log('INFO', ''); $this->log('INFO', 'Done. ' . ($failCount > 0 ? "{$failCount} failure(s)." : 'All succeeded.')); return $failCount > 0 ? 1 : 0; } private function buildRepoList(): ?array { if ($this->reposFile !== '') { if (!file_exists($this->reposFile)) { $this->log('ERROR', "Repos file not found: {$this->reposFile}"); return null; } $content = file_get_contents($this->reposFile); $lines = array_filter(array_map('trim', explode("\n", $content)), function (string $line): bool { return $line !== '' && $line[0] !== '#'; }); return array_values($lines); } // Fetch all repos from org $this->log('INFO', "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 for org (HTTP {$response['code']})."); return null; } break; } $data = json_decode($response['body'], true); if (!is_array($data) || count($data) === 0) { break; } foreach ($data as $repo) { $fullName = $repo['full_name'] ?? ''; if ($fullName !== '') { $repos[] = $fullName; } } $page++; } $this->log('INFO', 'Found ' . count($repos) . " repo(s) in org \"{$this->org}\"."); return $repos; } 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]; } } $app = new BulkWorkflowTriggerCli(); exit($app->execute());