From 116d94dd8cb3acd41a3779fbdf2fc3b40fe73f5c Mon Sep 17 00:00:00 2001 From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech> Date: Sat, 20 Jun 2026 17:30:33 +0000 Subject: [PATCH] fix: add branch+PR fallback for protected repos, rename moko-platform to mokocli --- cli/bulk_workflow_push.php | 716 +++++++++++++++++++++---------------- 1 file changed, 407 insertions(+), 309 deletions(-) diff --git a/cli/bulk_workflow_push.php b/cli/bulk_workflow_push.php index baff4bc..a04c2f3 100644 --- a/cli/bulk_workflow_push.php +++ b/cli/bulk_workflow_push.php @@ -1,309 +1,407 @@ -#!/usr/bin/env php - - * - * This file is part of a Moko Consulting project. - * - * SPDX-License-Identifier: GPL-3.0-or-later - * - * FILE INFORMATION - * DEFGROUP: mokoplatform.CLI - * INGROUP: mokoplatform - * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform - * 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 mokoplatform [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++; - } 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 mokoplatform [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++; - } 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++; - } - } - - 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()); +#!/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());