Public Access
634 lines
23 KiB
PHP
634 lines
23 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
|
|
/**
|
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
*
|
|
* 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.37.06
|
|
* 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());
|