From 0d3b14d55ca0c200451d888c1bb8434a53db81bc Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 20 Jun 2026 21:41:43 -0500 Subject: [PATCH] feat: cross-repo dependency update automation (#149) Add `deps:update` command that scans org repos for outdated Composer/npm dependencies, creates PRs with changelogs, and optionally auto-merges safe patch updates. - Composer: runs `composer outdated --format=json`, updates targeted packages - npm: runs `npm outdated --json`, updates targeted packages - Skips repos with existing deps PRs (no duplicates) - Checkpoint-based resumability with --resume - --patch-only for safe updates, --auto-merge for patch PRs - Supports --repos and --exclude filters --- automation/update_dependencies.php | 633 +++++++++++++++++++++++++++++ bin/moko | 1 + 2 files changed, 634 insertions(+) create mode 100644 automation/update_dependencies.php diff --git a/automation/update_dependencies.php b/automation/update_dependencies.php new file mode 100644 index 0000000..1380846 --- /dev/null +++ b/automation/update_dependencies.php @@ -0,0 +1,633 @@ +#!/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: 01.00.00 + * 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()); diff --git a/bin/moko b/bin/moko index db6a660..b6a58b6 100644 --- a/bin/moko +++ b/bin/moko @@ -89,6 +89,7 @@ const COMMAND_MAP = [ // Automation 'sync' => 'automation/bulk_sync.php', + 'deps:update' => 'automation/update_dependencies.php', 'automation:cleanup' => 'automation/repo_cleanup.php', 'automation:migrate-gitea' => 'automation/migrate_to_gitea.php',