#!/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/repo_cleanup.php * BRIEF: Enterprise repository cleanup โ€” branches, PRs, issues, workflows, labels, logs */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../lib/Enterprise/CliFramework.php'; use MokoCli\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory}; /** * Enterprise Repository Cleanup * * Comprehensive maintenance tool for governed repositories: * 1. Delete stale sync branches (keeps current versioned branch) * 2. Close superseded PRs on deleted branches * 3. Close/lock resolved tracking issues where linked PR is merged * 4. Delete retired workflow files from repos * 5. Clean cancelled/stale workflow runs * 6. Delete workflow run logs older than N days * 7. Verify and provision standard labels * 8. Version drift detection */ class RepoCleanup extends CliFramework { private const VERSION = '09.23.00'; private const SYNC_PREFIX = 'chore/sync-mokocli-'; private const CURRENT_BRANCH = 'chore/sync-mokocli-v04.02.00'; /** Workflow files that have been retired and should be deleted from governed repos. */ private const RETIRED_WORKFLOWS = [ 'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml', 'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml', 'flush-actions-cache.yml', 'mokocli-script-runner.yml', 'unified-ci.yml', 'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml', 'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml', 'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml', 'rebuild-docs-indexes.yml', 'setup-project-v2.yml', 'sync-docs-to-project.yml', 'release.yml', 'sync-changelogs.yml', 'version_branch.yml', 'publish-to-mokodolibarr.yml', 'ci.yml', 'deploy-rs.yml', ]; private ApiClient $api; private GitPlatformAdapter $adapter; protected bool $dryRun = false; private float $startTime; protected function configure(): void { $this->setDescription('Enterprise repository cleanup'); $this->addArgument('--org', 'GitHub organization', 'MokoConsulting'); $this->addArgument('--repos', 'Specific repos (space-separated)', ''); $this->addArgument('--skip-archived', 'Skip archived repos', false); $this->addArgument('--close-issues', 'Close resolved tracking issues', false); $this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false); $this->addArgument('--clean-workflows', 'Delete stale workflow runs', false); $this->addArgument('--clean-logs', 'Delete old workflow logs', false); $this->addArgument('--log-days', 'Days to keep logs', '30'); $this->addArgument('--delete-retired', 'Delete retired workflows', false); $this->addArgument('--check-labels', 'Verify labels exist', false); $this->addArgument('--check-drift', 'Check version drift', false); $this->addArgument('--all', 'Run all operations', false); $this->addArgument('--yes', 'Auto-confirm', false); $this->addArgument('--json', 'Output as JSON', false); } protected function run(): int { $this->startTime = microtime(true); $org = $this->getArgument('--org', 'MokoConsulting'); $this->dryRun = (bool) $this->getArgument('--dry-run', false); $runAll = (bool) $this->getArgument('--all', false); $config = Config::load(); try { $this->adapter = PlatformAdapterFactory::create($config); $this->api = $this->adapter->getApiClient(); } catch (\Exception $e) { $this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage()); return 1; } $this->logMsg("๐Ÿงน mokocli Repository Cleanup v" . self::VERSION); $this->logMsg("Organization: {$org}"); $this->logMsg("Current sync branch: " . self::CURRENT_BRANCH); if ($this->dryRun) { $this->logMsg("โš ๏ธ DRY RUN โ€” no changes will be made"); } $this->logMsg(''); $repos = $this->fetchRepositories($org); $this->logMsg("Found " . count($repos) . " repositories"); $this->logMsg(''); $results = [ 'repos_processed' => 0, 'repos_cleaned' => 0, 'branches_deleted' => 0, 'prs_closed' => 0, 'issues_closed' => 0, 'issues_locked' => 0, 'workflows_deleted' => 0, 'runs_deleted' => 0, 'logs_deleted' => 0, 'labels_missing' => 0, 'version_drift' => 0, 'retired_files' => 0, 'errors' => 0, ]; foreach ($repos as $i => $repo) { $name = $repo['name']; $num = $i + 1; $total = count($repos); $this->logMsg("[{$num}/{$total}] {$name}"); $results['repos_processed']++; try { $this->api->resetCircuitBreaker(); $cleaned = false; // Always: delete old sync branches + close their PRs $cleaned = $this->cleanBranches($org, $name, $results) || $cleaned; // Optional: close resolved issues if ($runAll || $this->getArgument('--close-issues', false)) { $cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned; } // Optional: lock old closed issues if ($runAll || $this->getArgument('--lock-old-issues', false)) { $cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned; } // Optional: delete retired workflow files if ($runAll || $this->getArgument('--delete-retired', false)) { $cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned; } // Optional: clean workflow runs if ($runAll || $this->getArgument('--clean-workflows', false)) { $cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned; } // Optional: clean old logs if ($runAll || $this->getArgument('--clean-logs', false)) { $cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned; } // Optional: check labels if ($runAll || $this->getArgument('--check-labels', false)) { $this->checkLabels($org, $name, $results); } // Optional: check version drift if ($runAll || $this->getArgument('--check-drift', false)) { $this->checkVersionDrift($org, $name, $results); } if ($cleaned) { $results['repos_cleaned']++; } } catch (\Exception $e) { $this->errorMsg(" โœ— {$name}: " . $e->getMessage()); $results['errors']++; } } $duration = round(microtime(true) - $this->startTime, 1); $this->logMsg(''); $this->logMsg('============================================================'); $this->logMsg("๐Ÿงน Cleanup Complete ({$duration}s)"); $this->logMsg('============================================================'); $this->logMsg("Repos processed: {$results['repos_processed']}"); $this->logMsg("Repos with changes: {$results['repos_cleaned']}"); $this->logMsg("Branches deleted: {$results['branches_deleted']}"); $this->logMsg("PRs closed: {$results['prs_closed']}"); $this->logMsg("Issues closed: {$results['issues_closed']}"); $this->logMsg("Issues locked: {$results['issues_locked']}"); $this->logMsg("Retired files: {$results['retired_files']}"); $this->logMsg("Workflow runs: {$results['runs_deleted']}"); $this->logMsg("Logs cleaned: {$results['logs_deleted']}"); $this->logMsg("Labels missing: {$results['labels_missing']}"); $this->logMsg("Version drift: {$results['version_drift']}"); $this->logMsg("Errors: {$results['errors']}"); $this->logMsg('============================================================'); if ($this->getArgument('--json', false)) { $results['duration_seconds'] = $duration; echo json_encode($results, JSON_PRETTY_PRINT) . "\n"; } return $results['errors'] > 0 ? 1 : 0; } // โ”€โ”€โ”€ Repository fetching โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ private function fetchRepositories(string $org): array { $specificRepos = trim((string) $this->getArgument('--repos', '')); $skipArchived = (bool) $this->getArgument('--skip-archived', false); if (!empty($specificRepos)) { $names = preg_split('/[\s,]+/', $specificRepos); return array_map(fn($n) => ['name' => trim($n), 'archived' => false], $names); } $allRepos = $this->adapter->listOrgRepos($org, $skipArchived); return array_filter($allRepos, fn($r) => !in_array($r['name'], ['mokocli', '.github-private'], true)); } // โ”€โ”€โ”€ Cleanup operations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ private function cleanBranches(string $org, string $repo, array &$results): bool { $changed = false; try { $branches = $this->api->get("/repos/{$org}/{$repo}/branches", ['per_page' => 100]); } catch (\Exception $e) { return false; } foreach ($branches as $branch) { $name = $branch['name'] ?? ''; if (!str_starts_with($name, self::SYNC_PREFIX) || $name === self::CURRENT_BRANCH) { continue; } // Close open PRs on this branch try { $prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [ 'state' => 'open', 'head' => "{$org}:{$name}", 'per_page' => 10, ]); foreach ($prs as $pr) { if (($pr['number'] ?? 0) > 0 && !$this->dryRun) { $this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']); } $this->logMsg(" ๐Ÿ”’ Closed PR #{$pr['number']} ({$name})"); $results['prs_closed']++; $changed = true; } } catch (\Exception $e) { /* non-fatal */ } if (!$this->dryRun) { try { $this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}"); } catch (\Exception $e) { continue; } } $this->logMsg(" ๐Ÿ—‘๏ธ Deleted branch: {$name}"); $results['branches_deleted']++; $changed = true; } return $changed; } private function closeResolvedIssues(string $org, string $repo, array &$results): bool { $changed = false; foreach (['standards-update', 'standards-drift'] as $label) { try { $issues = $this->api->get("/repos/{$org}/{$repo}/issues", [ 'labels' => $label, 'state' => 'open', 'per_page' => 10, ]); } catch (\Exception $e) { continue; } foreach ($issues as $issue) { $num = $issue['number'] ?? 0; $body = $issue['body'] ?? ''; if (preg_match('/\[#(\d+)\]/', $body, $m)) { $prNum = (int) $m[1]; try { $pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNum}"); if (!empty($pr['merged_at'])) { if (!$this->dryRun) { $this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", [ 'state' => 'closed', 'state_reason' => 'completed', ]); } $this->logMsg(" โœ… Closed issue #{$num} (PR #{$prNum} merged)"); $results['issues_closed']++; $changed = true; } } catch (\Exception $e) { /* non-fatal */ } } } } return $changed; } private function lockOldIssues(string $org, string $repo, array &$results): bool { $changed = false; $cutoff = date('Y-m-d\TH:i:s\Z', strtotime('-30 days')); try { $issues = $this->api->get("/repos/{$org}/{$repo}/issues", [ 'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc', ]); } catch (\Exception $e) { return false; } foreach ($issues as $issue) { $closedAt = $issue['closed_at'] ?? ''; $locked = $issue['locked'] ?? false; $num = $issue['number'] ?? 0; if ($locked || $closedAt > $cutoff || $num === 0) { continue; } if (!$this->dryRun) { try { $this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [ 'lock_reason' => 'resolved', ]); } catch (\Exception $e) { continue; } } $results['issues_locked']++; $changed = true; } if ($results['issues_locked'] > 0) { $this->logMsg(" ๐Ÿ”’ Locked {$results['issues_locked']} old closed issue(s)"); } return $changed; } private function deleteRetiredWorkflows(string $org, string $repo, array &$results): bool { $changed = false; $defaultBranch = 'main'; try { $repoInfo = $this->api->get("/repos/{$org}/{$repo}"); $defaultBranch = $repoInfo['default_branch'] ?? 'main'; } catch (\Exception $e) { /* fallback to main */ } // Check both workflow directories for retired workflows (supports dual-platform repos) $wfDirs = array_unique(['.github/workflows', '.mokogitea/workflows', $this->adapter->getWorkflowDir()]); foreach (self::RETIRED_WORKFLOWS as $wf) { foreach ($wfDirs as $wfDir) { $path = "{$wfDir}/{$wf}"; try { $file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}"); $sha = $file['sha'] ?? ''; if (empty($sha)) { continue; } if (!$this->dryRun) { $this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [ 'message' => "chore: delete retired workflow {$wf}", 'sha' => $sha, 'branch' => $defaultBranch, ]); } $this->logMsg(" Deleted retired: {$wf} (from {$wfDir})"); $results['retired_files']++; $changed = true; } catch (\Exception $e) { // File doesn't exist in this dir โ€” skip $this->api->resetCircuitBreaker(); } } } return $changed; } private function cleanWorkflowRuns(string $org, string $repo, array &$results): bool { $changed = false; foreach (['cancelled', 'stale'] as $status) { try { $runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [ 'status' => $status, 'per_page' => 100, ]); foreach (($runs['workflow_runs'] ?? []) as $run) { $id = $run['id'] ?? 0; if ($id > 0 && !$this->dryRun) { try { $this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}"); $results['runs_deleted']++; $changed = true; } catch (\Exception $e) { $this->api->resetCircuitBreaker(); } } } } catch (\Exception $e) { /* non-fatal */ } } if ($results['runs_deleted'] > 0) { $this->logMsg(" ๐Ÿ”„ Cleaned {$results['runs_deleted']} workflow run(s)"); } return $changed; } private function cleanOldLogs(string $org, string $repo, array &$results): bool { $changed = false; $days = (int) $this->getArgument('--log-days', '30'); $cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days")); try { $runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [ 'created' => "<{$cutoff}", 'per_page' => 100, ]); foreach (($runs['workflow_runs'] ?? []) as $run) { $id = $run['id'] ?? 0; if ($id > 0 && !$this->dryRun) { try { $this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs"); $results['logs_deleted']++; $changed = true; } catch (\Exception $e) { $this->api->resetCircuitBreaker(); } } } } catch (\Exception $e) { /* non-fatal */ } if ($results['logs_deleted'] > 0) { $this->logMsg(" ๐Ÿ“‹ Cleaned {$results['logs_deleted']} old log(s)"); } return $changed; } private function checkLabels(string $org, string $repo, array &$results): void { try { $this->api->get("/repos/{$org}/{$repo}/labels/mokocli"); } catch (\Exception $e) { $this->logMsg(" โš ๏ธ Missing 'mokocli' label"); $results['labels_missing']++; $this->api->resetCircuitBreaker(); } } private function checkVersionDrift(string $org, string $repo, array &$results): void { try { $file = $this->api->get("/repos/{$org}/{$repo}/contents/README.md"); $content = base64_decode($file['content'] ?? ''); if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) { $version = $m[1]; // Check manifest.xml for the tracked mokocli version try { $mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokogitea/manifest.xml"); $mokoContent = base64_decode($mokoFile['content'] ?? ''); if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) { if ($vm[1] !== self::VERSION) { $this->logMsg(" โš ๏ธ Standards drift: {$vm[1]} (expected " . self::VERSION . ")"); $results['version_drift']++; } } } catch (\Exception $e) { $this->api->resetCircuitBreaker(); } } } catch (\Exception $e) { $this->api->resetCircuitBreaker(); } } // โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ private function logMsg(string $message): void { if (!$this->quiet) { echo $message . "\n"; } } private function errorMsg(string $message): void { fwrite(STDERR, $message . "\n"); } } $app = new RepoCleanup(); exit($app->execute());