#!/usr/bin/env php * * This file is part of a Moko Consulting project. * * SPDX-License-Identifier: GPL-3.0-or-later * * FILE INFORMATION * DEFGROUP: MokoStandards.Automation * INGROUP: MokoStandards.Scripts * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /automation/push_files.php * BRIEF: Push one or more specific files to one or more remote repositories */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoEnterprise\{ ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory, ProjectTypeDetector }; /** * Targeted File Push Tool * * Pushes one or more specific files from MokoStandards templates to one or * more remote repositories — without running a full sync. * * Files are specified by their destination path as they appear in the target * repository (e.g., ".github/ISSUE_TEMPLATE/config.yml"). The tool looks up * the matching source template from the appropriate platform definition. * * Files may also be given as "source:destination" pairs to bypass definition * lookup and push any arbitrary local file. * * Usage: * php push_files.php --files=.github/ISSUE_TEMPLATE/config.yml --repos=MokoCRM * php push_files.php --files=".github/workflows/ci.yml,.github/workflows/codeql-analysis.yml" --repos=MokoCRM,WaasComponent * php push_files.php --files=templates/foo.txt:docs/foo.txt --repos=MyRepo --direct */ class PushFiles extends CliFramework { public const DEFAULT_ORG = 'MokoConsulting'; public const VERSION = '04.06.00'; private ApiClient $api; private GitPlatformAdapter $adapter; private AuditLogger $logger; private ProjectTypeDetector $typeDetector; /** * Setup command-line arguments */ protected function configure(): void { $this->setDescription('Push files to remote repositories'); $this->addArgument('--org', 'GitHub organization', self::DEFAULT_ORG); $this->addArgument('--repos', 'Target repos (comma-separated)', ''); $this->addArgument('--files', 'Files to push (comma-separated)', ''); $this->addArgument('--message', 'Custom commit message', ''); $this->addArgument('--branch', 'Target branch for direct pushes', ''); $this->addArgument('--direct', 'Push directly instead of PR', false); $this->addArgument('--yes', 'Auto-confirm without prompting', false); $this->addArgument('--no-issue', 'Skip creating tracking issue', false); } /** * Main execution */ protected function run(): int { $this->log('šŸ“¦ MokoStandards File Push v' . self::VERSION, 'INFO'); if (!$this->initializeComponents()) { return 1; } $org = $this->getArgument('--org', self::DEFAULT_ORG); $reposArg = $this->getArgument('--repos', ''); $filesArg = $this->getArgument('--files', ''); $direct = $this->getArgument('--direct', false); $autoYes = $this->getArgument('--yes', false); // Validate required arguments if (empty($reposArg)) { $this->log('āŒ --repos is required. Specify one or more repository names.', 'ERROR'); $this->log(' Example: --repos=MokoCRM,WaasComponent', 'ERROR'); return 1; } if (empty($filesArg)) { $this->log('āŒ --files is required. Specify destination paths or source:destination pairs.', 'ERROR'); $this->log(' Example: --files=.github/ISSUE_TEMPLATE/config.yml', 'ERROR'); return 1; } $repos = $this->parseList($reposArg); $files = $this->parseList($filesArg); $this->log("Organisation: {$org}", 'INFO'); $this->log('Repositories: ' . implode(', ', $repos), 'INFO'); $this->log('Files: ' . implode(', ', $files), 'INFO'); $this->log('Mode: ' . ($direct ? 'direct commit' : 'pull request'), 'INFO'); // Resolve file mappings for each repo $this->log("\nšŸ” Resolving file mappings...", 'INFO'); $repoFileMaps = $this->buildRepoFileMaps($org, $repos, $files); if (empty($repoFileMaps)) { $this->log('āŒ No files could be resolved. Check file paths and platform definitions.', 'ERROR'); return 1; } // Confirm before proceeding if (!$autoYes && !$this->confirmPush($repoFileMaps, $direct)) { $this->log('āŒ Cancelled.', 'INFO'); return 0; } // Execute pushes $results = $this->executePushes($org, $repoFileMaps, $direct); $this->displayResults($results); if ($results['failed'] > 0 && !isset($this->options['no-issue']) && !$this->dryRun) { $this->createFailureIssue($org, $results); } return $results['failed'] > 0 ? 1 : 0; } /** * Initialize enterprise components */ private function initializeComponents(): bool { $config = Config::load(); try { $this->adapter = PlatformAdapterFactory::create($config); $this->api = $this->adapter->getApiClient(); $this->logger = new AuditLogger('push_files'); $this->typeDetector = new ProjectTypeDetector($this->logger); $platform = $this->adapter->getPlatformName(); $this->log("āœ“ Components initialized for platform: {$platform}", 'INFO'); return true; } catch (\Exception $e) { $this->log('āŒ Failed to initialize: ' . $e->getMessage(), 'ERROR'); return false; } } /** * Parse a comma- or space-separated list into a clean array */ private function parseList(string $input): array { return array_values(array_filter( array_map('trim', preg_split('/[\s,]+/', $input)), fn($v) => $v !== '' )); } /** * Build per-repo file maps: repo → [ [source, destination], … ] * * Each entry in $files is either: * - "destination/path" → looked up in the platform definition * - "source/path:destination/path" → used as-is (raw mode) * * @param string[] $repos * @param string[] $files * @return array> */ private function buildRepoFileMaps(string $org, array $repos, array $files): array { $repoRoot = dirname(__DIR__, 2); $maps = []; foreach ($repos as $repo) { // Detect the repo's platform so we load the right definition $platform = $this->detectRepoPlatform($org, $repo); $this->log(" {$repo}: platform = {$platform}", 'INFO'); $resolved = []; foreach ($files as $fileSpec) { if (str_contains($fileSpec, ':')) { // Raw source:destination pair [$src, $dest] = explode(':', $fileSpec, 2); } else { // Same path as source and destination $src = $fileSpec; $dest = $fileSpec; } $dest = ltrim($dest, '/'); $srcAbs = rtrim($repoRoot, '/') . '/' . ltrim($src, '/'); if (!file_exists($srcAbs)) { $this->log(" āš ļø Source not found for {$repo}: {$src}", 'WARN'); continue; } $resolved[] = ['source' => $srcAbs, 'destination' => $dest]; $this->log(" āœ“ {$dest}", 'INFO'); } if (!empty($resolved)) { $maps[$repo] = $resolved; } } return $maps; } /** * Detect platform for a repo via manifest or live detection. */ private function detectRepoPlatform(string $org, string $repo): string { // Read platform from repo's .mokogitea/manifest.xml via API try { $manifestData = $this->adapter->getFileContent($org, $repo, '.mokogitea/manifest.xml', 'main'); if (!empty($manifestData)) { $xml = @simplexml_load_string($manifestData); if ($xml !== false) { $platform = (string)($xml->governance->platform ?? ''); if (!empty($platform)) { return $platform; } } } } catch (\Exception $e) { // Fall through to local detection } // Fall back to live detection try { $result = $this->typeDetector->detect('.'); return $result['type'] ?? 'default'; } catch (\Exception $e) { $this->log(" āš ļø Could not detect platform for {$repo}, using 'default'", 'WARN'); return 'default'; } } /** * Prompt for confirmation before pushing * * @param array> $repoFileMaps */ private function confirmPush(array $repoFileMaps, bool $direct): bool { if ($this->quiet) { return true; } $totalFiles = array_sum(array_map('count', $repoFileMaps)); $totalRepos = count($repoFileMaps); $mode = $direct ? 'direct commit' : 'PR'; echo "\n"; foreach ($repoFileMaps as $repo => $entries) { echo " {$repo}:\n"; foreach ($entries as $entry) { echo " → {$entry['destination']}\n"; } } echo "\n"; echo "āš ļø About to push {$totalFiles} file(s) to {$totalRepos} repo(s) via {$mode}.\n"; echo "Continue? [y/N]: "; $handle = fopen('php://stdin', 'r'); $line = fgets($handle); if ($handle) { fclose($handle); } return is_string($line) && strtolower(trim($line)) === 'y'; } /** * Execute all file pushes * * @param array> $repoFileMaps * @return array{total: int, success: int, failed: int, repos: array} */ private function executePushes(string $org, array $repoFileMaps, bool $direct): array { $results = [ 'total' => count($repoFileMaps), 'success' => 0, 'failed' => 0, 'repos' => [], ]; $customMessage = $this->getArgument('--message', ''); $targetBranch = $this->getArgument('--branch', ''); foreach ($repoFileMaps as $repo => $entries) { $this->log("\n[{$repo}] Pushing " . count($entries) . ' file(s)...', 'INFO'); try { // Resolve the default branch $repoData = $this->adapter->getRepo($org, $repo); $defaultBranch = $repoData['default_branch'] ?? 'main'; $branch = $direct ? ($targetBranch ?: $defaultBranch) : $this->createSyncBranch($org, $repo, $defaultBranch); $pushed = 0; foreach ($entries as $entry) { if ($this->pushSingleFile($org, $repo, $entry['source'], $entry['destination'], $branch, $customMessage)) { $pushed++; $this->log(" āœ“ {$entry['destination']}", 'INFO'); } else { $this->log(" āœ— {$entry['destination']}", 'ERROR'); } } if ($pushed === 0) { $results['failed']++; $results['repos'][$repo] = 'failed'; continue; } $prNumber = null; if (!$direct) { $prTitle = "chore: push " . count($entries) . " file(s) from MokoStandards"; $prBody = $this->buildPRBody($entries); $pr = $this->adapter->createPullRequest( $org, $repo, $prTitle, $branch, $defaultBranch, $prBody, ['assignees' => ['jmiller']] ); $prNumber = $pr['number'] ?? null; $this->log(" šŸ“‹ PR #{$prNumber} created", 'INFO'); $results['repos'][$repo] = "pr#{$prNumber}"; } else { $results['repos'][$repo] = 'pushed'; } if (!isset($this->options['no-issue']) && !$this->dryRun) { $this->createTargetRepoIssue($org, $repo, $entries, $prNumber, $direct ? $branch : null); } $results['success']++; } catch (\Exception $e) { $this->log(" āœ— {$repo}: " . $e->getMessage(), 'ERROR'); $results['failed']++; $results['repos'][$repo] = 'failed'; } } return $results; } /** * Create a uniquely-named sync branch off the default branch */ private function createSyncBranch(string $org, string $repo, string $base): string { $branchName = 'moko/push-files-' . date('Ymd-His'); // Resolve the base branch to a commit SHA using the adapter $sha = $this->adapter->resolveRef($org, $repo, $base); if (empty($sha)) { throw new \RuntimeException("Cannot resolve SHA for branch {$base} in {$repo}"); } $this->api->post("/repos/{$org}/{$repo}/git/refs", [ 'ref' => "refs/heads/{$branchName}", 'sha' => $sha, ]); $this->log(" 🌿 Branch created: {$branchName}", 'INFO'); return $branchName; } /** * Push a single file to a repository branch via the Contents API * * @return bool True on success */ private function pushSingleFile( string $org, string $repo, string $sourcePath, string $destPath, string $branch, string $customMessage ): bool { $content = file_get_contents($sourcePath); if ($content === false) { $this->log(" āš ļø Cannot read source: {$sourcePath}", 'WARN'); return false; } $message = !empty($customMessage) ? $customMessage : "chore: update {$destPath} from MokoStandards"; // Fetch existing file SHA (needed for updates) $existingSha = null; try { $existing = $this->adapter->getFileContents($org, $repo, $destPath, $branch); $existingSha = $existing['sha'] ?? null; } catch (\Exception $e) { // File does not exist — create it (no sha needed) $this->adapter->getApiClient()->resetCircuitBreaker(); } try { $this->adapter->createOrUpdateFile( $org, $repo, $destPath, $content, $message, $existingSha, $branch ); return true; } catch (\Exception $e) { $this->log(" āœ— API error pushing {$destPath}: " . $e->getMessage(), 'ERROR'); return false; } } /** * Create a tracking issue in the target repository after a successful push. * * @param list $entries */ private function createTargetRepoIssue( string $org, string $repo, array $entries, ?int $prNumber, ?string $directBranch ): void { $now = gmdate('Y-m-d H:i:s') . ' UTC'; $version = self::VERSION; $source = $this->adapter->getRepoWebUrl($org, 'MokoStandards'); $title = "chore: MokoStandards file push tracking"; $deliveryLine = $prNumber !== null ? "| **Pull request** | [#{$prNumber}](" . $this->adapter->getPullRequestWebUrl($org, $repo, $prNumber) . ") |" : "| **Delivery** | Direct commit to `{$directBranch}` |"; $fileRows = implode("\n", array_map( fn($e) => "- `{$e['destination']}`", $entries )); $body = <<api->get("/repos/{$org}/{$repo}/issues", [ 'labels' => 'standards-update', 'state' => 'all', 'per_page' => 1, 'sort' => 'created', 'direction' => 'desc', ]); $existing = array_values($existing); if (!empty($existing) && isset($existing[0]['number'])) { $num = $existing[0]['number']; $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; if (($existing[0]['state'] ?? 'open') === 'closed') { $patch['state'] = 'open'; } $this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", $patch); try { $this->api->post("/repos/{$org}/{$repo}/issues/{$num}/labels", ['labels' => $labels]); } catch (\Exception $le) { /* non-fatal */ } $this->log(" šŸ“‹ Tracking issue #{$num} updated in {$repo}", 'INFO'); } else { $issue = $this->api->post("/repos/{$org}/{$repo}/issues", [ 'title' => $title, 'body' => $body, 'labels' => $labels, 'assignees' => ['jmiller'], ]); $num = $issue['number'] ?? null; $this->log(" šŸ“‹ Tracking issue #{$num} created in {$repo}", 'INFO'); } // Cross-link: patch the sync PR body to reference the tracking issue // so GitHub shows it in the PR's Development sidebar. if ($prNumber !== null && is_int($num)) { try { $pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNumber}"); $currentBody = $pr['body'] ?? ''; $ref = "Linked to #{$num}"; if (!str_contains($currentBody, $ref)) { $this->api->patch("/repos/{$org}/{$repo}/pulls/{$prNumber}", [ 'body' => $ref . "\n\n" . $currentBody, ]); } } catch (\Exception $le) { /* non-fatal */ } } } catch (\Exception $e) { $this->log(" āš ļø Could not create/update tracking issue in {$repo}: " . $e->getMessage(), 'WARN'); } } /** * Create or update a failure issue in MokoStandards when repos fail to receive files. * Uses the 'push-failure' label. Reopens a closed issue rather than creating a duplicate. */ private function createFailureIssue(string $org, array $results): void { $now = gmdate('Y-m-d H:i:s') . ' UTC'; $failed = $results['failed']; $version = self::VERSION; $failedRepos = array_keys(array_filter( $results['repos'] ?? [], fn($s) => $s === 'failed' )); $repoList = implode("\n", array_map(fn($r) => "- `{$r}`", $failedRepos)); $fileArgs = $this->getArgument('--files', ''); $title = "fix: push_files failed for {$failed} repo(s) — action required"; $body = << --files= --yes` 4. Close this issue once resolved. --- *Auto-created by `push_files.php` — close once resolved.* MD; $body = preg_replace('/^ /m', '', $body); try { $existing = $this->api->get("/repos/{$org}/MokoStandards/issues", [ 'labels' => 'push-failure', 'state' => 'all', 'per_page' => 1, 'sort' => 'created', 'direction' => 'desc', ]); $existing = array_values($existing); if (!empty($existing) && isset($existing[0]['number'])) { $num = $existing[0]['number']; $patch = ['title' => $title, 'body' => $body, 'assignees' => ['jmiller']]; if (($existing[0]['state'] ?? 'open') === 'closed') { $patch['state'] = 'open'; } $this->api->patch("/repos/{$org}/MokoStandards/issues/{$num}", $patch); $this->log("🚨 Failure issue #{$num} updated: {$org}/MokoStandards#{$num}", 'WARN'); } else { $issue = $this->api->post("/repos/{$org}/MokoStandards/issues", [ 'title' => $title, 'body' => $body, 'labels' => ['push-failure'], 'assignees' => ['jmiller'], ]); $num = $issue['number'] ?? '?'; $this->log("🚨 Failure issue created: {$org}/MokoStandards#{$num}", 'WARN'); } } catch (\Exception $e) { $this->log("āš ļø Could not create/update failure issue: " . $e->getMessage(), 'WARN'); } } /** * Build a markdown PR body listing every pushed file * * @param list $entries */ private function buildPRBody(array $entries): string { $now = gmdate('Y-m-d H:i:s') . ' UTC'; $lines = ["## MokoStandards File Push\n", "**Pushed:** {$now}\n", '### Files\n']; foreach ($entries as $entry) { $lines[] = "- `{$entry['destination']}`"; } $sourceUrl = $this->adapter->getRepoWebUrl(self::DEFAULT_ORG, 'MokoStandards'); $lines[] = "\n---\n*Generated by [MokoStandards]({$sourceUrl}) `push_files.php`*"; return implode("\n", $lines); } /** * Display final results * * @param array{total: int, success: int, failed: int, repos: array} $results */ private function displayResults(array $results): void { $this->log("\n" . str_repeat('=', 60), 'INFO'); $this->log('šŸ“Š Push Complete', 'INFO'); $this->log(str_repeat('=', 60), 'INFO'); $this->log(sprintf('Total: %d repos', $results['total']), 'INFO'); $this->log(sprintf('Success: %d', $results['success']), 'INFO'); $this->log(sprintf('Failed: %d', $results['failed']), 'INFO'); if ($this->verbose) { $this->log("\nšŸ“‹ Details:", 'INFO'); foreach ($results['repos'] as $repo => $outcome) { $icon = str_starts_with($outcome, 'pr#') || $outcome === 'pushed' ? 'āœ“' : 'āœ—'; $this->log(" {$icon} {$repo}: {$outcome}", 'INFO'); } } $this->log(str_repeat('=', 60), 'INFO'); } } // Execute if run directly if (php_sapi_name() === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) { $app = new PushFiles(); exit($app->execute()); }