#!/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.Automation * INGROUP: MokoPlatform.Scripts * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli * PATH: /automation/update_dependencies.php * VERSION: 09.38.01 * BRIEF: Cross-repo dependency update automation — scan, update, PR, auto-merge */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; use MokoCli\{ ApiClient, AuditLogger, CheckpointManager, CircuitBreakerOpen, CliFramework, Config, GitPlatformAdapter, PlatformAdapterFactory, RateLimitExceeded }; /** * Cross-Repo Dependency Update Automation * * Scans org repos for outdated Composer/npm dependencies, creates PRs with * changelogs, and optionally auto-merges safe patch updates. * * @see https://git.mokoconsulting.tech/MokoConsulting/mokocli/issues/149 */ class UpdateDependencies extends CliFramework { public const VERSION = '01.00.00'; private const BRANCH_PREFIX = 'chore/deps-update'; private ApiClient $api; private GitPlatformAdapter $adapter; private AuditLogger $logger; private CheckpointManager $checkpoints; /** Summary counters. */ private int $reposScanned = 0; private int $reposUpdated = 0; private int $prsCreated = 0; private int $autoMerged = 0; private int $reposFailed = 0; protected function configure(): void { $this->setDescription('Cross-repo dependency update automation'); $this->addArgument('--org', 'Organization to scan', 'MokoConsulting'); $this->addArgument('--repos', 'Comma-separated list of specific repos', ''); $this->addArgument('--exclude', 'Comma-separated list of repos to exclude', ''); $this->addArgument('--skip-archived', 'Skip archived repositories', true); $this->addArgument('--type', 'Dependency type: composer, npm, or all', 'all'); $this->addArgument('--patch-only', 'Only update patch versions (safe updates)', false); $this->addArgument('--auto-merge', 'Auto-merge PRs with only patch updates', false); $this->addArgument('--resume', 'Resume from checkpoint', false); } protected function run(): int { $this->log("Dependency Update Automation v" . self::VERSION, 'INFO'); if (!$this->initComponents()) { return self::EXIT_FAILURE; } $org = $this->getArgument('--org', 'MokoConsulting'); $depType = strtolower($this->getArgument('--type', 'all')); $patchOnly = $this->getArgument('--patch-only', false); $autoMerge = $this->getArgument('--auto-merge', false); // ── Gather repos ───────────────────────────────────────────────── $repos = $this->gatherRepos($org); if ($repos === null) { return self::EXIT_FAILURE; } $total = count($repos); $this->log("Found {$total} repositories to scan", 'INFO'); // ── Resume support ─────────────────────────────────────────────── $completed = []; if ($this->getArgument('--resume', false)) { $checkpoint = $this->checkpoints->load('deps_update'); if ($checkpoint) { $completed = $checkpoint['completed'] ?? []; $this->log("Resuming — skipping " . count($completed) . " already-processed repos", 'INFO'); } } // ── Process each repo ──────────────────────────────────────────── $this->section('Scanning repositories for outdated dependencies'); foreach ($repos as $i => $repo) { $repoName = $repo['name']; $this->progress($i + 1, $total, $repoName); if (in_array($repoName, $completed, true)) { continue; } try { $this->processRepo($org, $repoName, $depType, $patchOnly, $autoMerge); $completed[] = $repoName; $this->checkpoints->save('deps_update', ['completed' => $completed]); } catch (RateLimitExceeded $e) { $this->log("Rate limit hit — checkpoint saved", 'WARNING'); break; } catch (CircuitBreakerOpen $e) { $this->log("Circuit breaker open — checkpoint saved", 'WARNING'); break; } catch (\Exception $e) { $this->log("Failed {$repoName}: {$e->getMessage()}", 'ERROR'); $this->reposFailed++; } } $this->progress($total, $total, '', true); // ── Summary ────────────────────────────────────────────────────── $this->section('Summary'); $this->printSummary( $this->reposScanned - $this->reposFailed, $this->reposFailed, $this->elapsed() ); $this->log("Repos scanned: {$this->reposScanned}", 'INFO'); $this->log("Repos updated: {$this->reposUpdated}", 'INFO'); $this->log("PRs created: {$this->prsCreated}", 'INFO'); if ($autoMerge) { $this->log("Auto-merged: {$this->autoMerged}", 'INFO'); } if (count($completed) === $total) { $this->checkpoints->clear('deps_update'); } return $this->reposFailed > 0 ? self::EXIT_FAILURE : self::EXIT_SUCCESS; } // ── Component init ─────────────────────────────────────────────────── private function initComponents(): bool { try { $config = new Config(); $this->api = new ApiClient($config); $this->adapter = PlatformAdapterFactory::create($this->api, $config); $this->logger = new AuditLogger(); $this->checkpoints = new CheckpointManager(); return true; } catch (\Exception $e) { $this->log("Failed to initialise: {$e->getMessage()}", 'ERROR'); return false; } } // ── Repo gathering ─────────────────────────────────────────────────── private function gatherRepos(string $org): ?array { $specificRepos = array_filter(explode(',', $this->getArgument('--repos', ''))); $excludeRepos = array_filter(explode(',', $this->getArgument('--exclude', ''))); $skipArchived = $this->getArgument('--skip-archived', true); // Default exclusions $excludeRepos = array_merge($excludeRepos, [ 'mokocli', '.mokogitea-private', 'org-profile', ]); try { $repos = $this->adapter->listOrgRepos($org, $skipArchived); } catch (\Exception $e) { $this->log("Failed to list repos: {$e->getMessage()}", 'ERROR'); return null; } if (!empty($specificRepos)) { $repos = array_filter($repos, fn($r) => in_array($r['name'], $specificRepos, true)); } if (!empty($excludeRepos)) { $repos = array_filter($repos, fn($r) => !in_array($r['name'], $excludeRepos, true)); } return array_values($repos); } // ── Per-repo processing ────────────────────────────────────────────── private function processRepo( string $org, string $repoName, string $depType, bool $patchOnly, bool $autoMerge ): void { $this->reposScanned++; $hasComposer = ($depType === 'all' || $depType === 'composer'); $hasNpm = ($depType === 'all' || $depType === 'npm'); $outdated = []; // ── Composer ───────────────────────────────────────────────── if ($hasComposer) { $composerOutdated = $this->scanComposer($org, $repoName, $patchOnly); if ($composerOutdated !== null) { $outdated['composer'] = $composerOutdated; } } // ── npm ────────────────────────────────────────────────────── if ($hasNpm) { $npmOutdated = $this->scanNpm($org, $repoName, $patchOnly); if ($npmOutdated !== null) { $outdated['npm'] = $npmOutdated; } } if (empty($outdated)) { return; } // Check if there's already an open deps PR if ($this->hasExistingDepsPR($org, $repoName)) { $this->log(" {$repoName}: existing deps PR found — skipping", 'INFO'); return; } $this->reposUpdated++; // ── Create PR ──────────────────────────────────────────────── $totalUpdates = 0; $allPatchOnly = true; foreach ($outdated as $type => $packages) { $totalUpdates += count($packages); foreach ($packages as $pkg) { if (!$this->isPatchUpdate($pkg['current'] ?? '', $pkg['latest'] ?? '')) { $allPatchOnly = false; } } } $title = "chore(deps): update {$totalUpdates} " . ($totalUpdates === 1 ? 'dependency' : 'dependencies'); $body = $this->buildPrBody($repoName, $outdated); $branch = self::BRANCH_PREFIX . '-' . date('Y-m-d'); if ($this->dryRun) { $this->log("[dry-run] Would create PR in {$repoName}: {$title}", 'INFO'); foreach ($outdated as $type => $packages) { foreach ($packages as $pkg) { $this->log(" [{$type}] {$pkg['name']}: {$pkg['current']} → {$pkg['latest']}", 'INFO'); } } return; } try { // Clone repo, run updates, push branch $prNumber = $this->cloneUpdateAndPR($org, $repoName, $branch, $title, $body, $outdated); if ($prNumber > 0) { $this->prsCreated++; $this->log(" {$repoName}: PR #{$prNumber} created", 'INFO'); // Auto-merge if all updates are patch-level if ($autoMerge && $allPatchOnly && $prNumber > 0) { $this->tryAutoMerge($org, $repoName, $prNumber); } } } catch (\Exception $e) { $this->log(" {$repoName}: PR creation failed — {$e->getMessage()}", 'ERROR'); } } // ── Composer scanning ──────────────────────────────────────────────── private function scanComposer(string $org, string $repoName, bool $patchOnly): ?array { // Check if repo has composer.json try { $this->adapter->getFileContents($org, $repoName, 'composer.json'); } catch (\Exception $e) { return null; } // Check if repo has composer.lock try { $this->adapter->getFileContents($org, $repoName, 'composer.lock'); } catch (\Exception $e) { return null; } // Clone to temp dir and run composer outdated $tmpDir = sys_get_temp_dir() . '/moko_deps_' . $repoName . '_' . getmypid(); @mkdir($tmpDir, 0700, true); try { $cloneUrl = $this->adapter->getCloneUrl($org, $repoName); $cmd = sprintf( 'git clone --depth 1 --quiet %s %s 2>/dev/null', escapeshellarg($cloneUrl), escapeshellarg($tmpDir) ); exec($cmd, $output, $exitCode); if ($exitCode !== 0) { return null; } // Run composer outdated $flags = $patchOnly ? '--minor-only' : ''; $cmd = sprintf( 'composer outdated --format=json --no-interaction %s --working-dir=%s 2>/dev/null', $flags, escapeshellarg($tmpDir) ); $json = shell_exec($cmd); if ($json === null || $json === '') { return null; } $data = json_decode($json, true); $installed = $data['installed'] ?? []; if (empty($installed)) { return null; } $outdated = []; foreach ($installed as $pkg) { // Skip abandoned/dev packages if (($pkg['abandoned'] ?? false) || str_starts_with($pkg['version'] ?? '', 'dev-')) { continue; } $outdated[] = [ 'name' => $pkg['name'] ?? '', 'current' => $pkg['version'] ?? '', 'latest' => $pkg['latest'] ?? '', 'status' => $pkg['latest-status'] ?? 'unknown', ]; } return empty($outdated) ? null : $outdated; } finally { // Cleanup if (is_dir($tmpDir)) { exec(sprintf('rm -rf %s', escapeshellarg($tmpDir))); } } } // ── npm scanning ───────────────────────────────────────────────────── private function scanNpm(string $org, string $repoName, bool $patchOnly): ?array { // Check if repo has package.json try { $this->adapter->getFileContents($org, $repoName, 'package.json'); } catch (\Exception $e) { return null; } // Check for lock file $hasLock = false; foreach (['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'] as $lockFile) { try { $this->adapter->getFileContents($org, $repoName, $lockFile); $hasLock = true; break; } catch (\Exception $e) { // continue } } if (!$hasLock) { return null; } $tmpDir = sys_get_temp_dir() . '/moko_deps_npm_' . $repoName . '_' . getmypid(); @mkdir($tmpDir, 0700, true); try { $cloneUrl = $this->adapter->getCloneUrl($org, $repoName); exec(sprintf('git clone --depth 1 --quiet %s %s 2>/dev/null', escapeshellarg($cloneUrl), escapeshellarg($tmpDir))); if (!file_exists("{$tmpDir}/package.json")) { return null; } // Install deps first (needed for npm outdated) exec(sprintf('cd %s && npm install --silent 2>/dev/null', escapeshellarg($tmpDir))); $json = shell_exec(sprintf('cd %s && npm outdated --json 2>/dev/null', escapeshellarg($tmpDir))); if ($json === null || $json === '' || $json === '{}') { return null; } $data = json_decode($json, true); if (!is_array($data) || empty($data)) { return null; } $outdated = []; foreach ($data as $name => $info) { $current = $info['current'] ?? ''; $wanted = $info['wanted'] ?? ''; $latest = $info['latest'] ?? ''; $target = $patchOnly ? $wanted : $latest; if ($current === $target || $target === '') { continue; } $outdated[] = [ 'name' => $name, 'current' => $current, 'latest' => $target, 'status' => ($current === $wanted) ? 'up-to-date' : 'outdated', ]; } return empty($outdated) ? null : $outdated; } finally { if (is_dir($tmpDir)) { exec(sprintf('rm -rf %s', escapeshellarg($tmpDir))); } } } // ── PR creation ────────────────────────────────────────────────────── private function cloneUpdateAndPR( string $org, string $repoName, string $branch, string $title, string $body, array $outdated ): int { $tmpDir = sys_get_temp_dir() . '/moko_deps_pr_' . $repoName . '_' . getmypid(); @mkdir($tmpDir, 0700, true); try { $cloneUrl = $this->adapter->getCloneUrl($org, $repoName); exec(sprintf('git clone --quiet %s %s 2>/dev/null', escapeshellarg($cloneUrl), escapeshellarg($tmpDir))); // Create branch exec(sprintf('git -C %s checkout -b %s 2>/dev/null', escapeshellarg($tmpDir), escapeshellarg($branch))); $updated = false; // Run composer update if needed if (isset($outdated['composer'])) { $packages = array_column($outdated['composer'], 'name'); $cmd = sprintf( 'cd %s && composer update %s --no-interaction --quiet 2>/dev/null', escapeshellarg($tmpDir), implode(' ', array_map('escapeshellarg', $packages)) ); exec($cmd, $output, $exitCode); if ($exitCode === 0) { $updated = true; } } // Run npm update if needed if (isset($outdated['npm'])) { $packages = array_column($outdated['npm'], 'name'); $cmd = sprintf( 'cd %s && npm update %s --save 2>/dev/null', escapeshellarg($tmpDir), implode(' ', array_map('escapeshellarg', $packages)) ); exec($cmd, $output, $exitCode); if ($exitCode === 0) { $updated = true; } } if (!$updated) { return 0; } // Commit and push exec(sprintf('git -C %s config user.email "gitea-actions[bot]@mokoconsulting.tech"', escapeshellarg($tmpDir))); exec(sprintf('git -C %s config user.name "gitea-actions[bot]"', escapeshellarg($tmpDir))); exec(sprintf('git -C %s add -A', escapeshellarg($tmpDir))); // Check if there are actual changes exec(sprintf('git -C %s diff --cached --quiet', escapeshellarg($tmpDir)), $output, $diffExit); if ($diffExit === 0) { return 0; // No changes } exec(sprintf('git -C %s commit -m %s', escapeshellarg($tmpDir), escapeshellarg($title . " [skip ci]"))); exec(sprintf('git -C %s push origin %s 2>/dev/null', escapeshellarg($tmpDir), escapeshellarg($branch)), $output, $pushExit); if ($pushExit !== 0) { $this->log(" {$repoName}: push failed", 'ERROR'); return 0; } // Create PR via API $defaultBranch = $this->getDefaultBranch($org, $repoName); $pr = $this->adapter->createPullRequest( $org, $repoName, $title, $branch, $defaultBranch, $body, [ 'labels' => ['dependencies'], ] ); return (int) ($pr['number'] ?? 0); } finally { if (is_dir($tmpDir)) { exec(sprintf('rm -rf %s', escapeshellarg($tmpDir))); } } } // ── Auto-merge ─────────────────────────────────────────────────────── private function tryAutoMerge(string $org, string $repoName, int $prNumber): void { try { $this->api->put( "/repos/{$org}/{$repoName}/pulls/{$prNumber}/merge", ['Do' => 'squash', 'merge_message_field' => 'chore(deps): auto-merge patch updates'] ); $this->autoMerged++; $this->log(" {$repoName}: PR #{$prNumber} auto-merged", 'INFO'); } catch (\Exception $e) { $this->log(" {$repoName}: auto-merge failed — {$e->getMessage()}", 'WARNING'); } } // ── Helpers ─────────────────────────────────────────────────────────── private function hasExistingDepsPR(string $org, string $repoName): bool { try { $prs = $this->adapter->listPullRequests($org, $repoName, ['state' => 'open']); foreach ($prs as $pr) { if (str_starts_with($pr['head']['ref'] ?? '', self::BRANCH_PREFIX)) { return true; } } } catch (\Exception $e) { // Ignore — proceed with creating PR } return false; } private function getDefaultBranch(string $org, string $repoName): string { try { $repo = $this->api->get("/repos/{$org}/{$repoName}"); return $repo['default_branch'] ?? 'main'; } catch (\Exception $e) { return 'main'; } } private function isPatchUpdate(string $current, string $latest): bool { $cur = explode('.', ltrim($current, 'v')); $lat = explode('.', ltrim($latest, 'v')); if (count($cur) < 3 || count($lat) < 3) { return false; } // Same major and minor, only patch differs return $cur[0] === $lat[0] && $cur[1] === $lat[1] && $cur[2] !== $lat[2]; } private function buildPrBody(string $repoName, array $outdated): string { $lines = [ "## Dependency Updates", "", "**Repository**: `{$repoName}`", "**Scanned**: " . date('Y-m-d H:i:s'), "", ]; foreach ($outdated as $type => $packages) { $lines[] = "### " . ucfirst($type); $lines[] = ""; $lines[] = "| Package | Current | Latest | Type |"; $lines[] = "|---------|---------|--------|------|"; foreach ($packages as $pkg) { $updateType = $this->isPatchUpdate($pkg['current'], $pkg['latest']) ? 'patch' : 'minor/major'; $lines[] = "| `{$pkg['name']}` | {$pkg['current']} | {$pkg['latest']} | {$updateType} |"; } $lines[] = ""; } $lines[] = "---"; $lines[] = "*Auto-generated by `moko deps:update`*"; return implode("\n", $lines); } } $script = new UpdateDependencies('update_dependencies', 'Cross-repo dependency update automation'); exit($script->execute());