#!/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 * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform * PATH: /automation/migrate_to_gitea.php * BRIEF: Migrate repositories from GitHub to self-hosted Gitea instance * * USAGE * php automation/migrate_to_gitea.php --dry-run * php automation/migrate_to_gitea.php --repos MokoCRM MokoDoliMods * php automation/migrate_to_gitea.php --exclude MokoStandards --skip-archived * php automation/migrate_to_gitea.php --resume */ declare(strict_types=1); require_once __DIR__ . '/../vendor/autoload.php'; use MokoEnterprise\CheckpointManager; use MokoEnterprise\CliFramework; use MokoEnterprise\Config; use MokoEnterprise\PlatformAdapterFactory; use MokoEnterprise\GitHubAdapter; use MokoEnterprise\MokoGiteaAdapter; /** * Gitea Migration Script * * Migrates repositories from GitHub to a self-hosted Gitea instance. * Uses Gitea's built-in migration endpoint for git history, tags, releases, * issues, and labels. Post-migration applies branch protection, topics, * and workflow conversion. */ class MigrateToGitea extends CliFramework { private ?GitHubAdapter $github = null; private ?MokoGiteaAdapter $gitea = null; private ?CheckpointManager $checkpoints = null; protected function configure(): void { $this->setDescription('Migrate repositories from GitHub to Gitea'); $this->addArgument('--dry-run', 'Show what would be migrated without making changes', false); $this->addArgument('--repos', 'Specific repositories to migrate (space-separated)', ''); $this->addArgument('--exclude', 'Repositories to exclude (space-separated)', ''); $this->addArgument('--skip-archived', 'Skip archived repositories', false); $this->addArgument('--resume', 'Resume from last checkpoint', false); $this->addArgument('--github-token', 'GitHub token override', ''); $this->addArgument('--gitea-token', 'Gitea token override', ''); } protected function run(): int { $dryRun = (bool) $this->getArgument('--dry-run'); $specificRepos = array_filter(explode(' ', (string) $this->getArgument('--repos'))); $excludeRepos = array_filter(explode(' ', (string) $this->getArgument('--exclude'))); $skipArchived = (bool) $this->getArgument('--skip-archived'); $resume = (bool) $this->getArgument('--resume'); $config = Config::load(); // Override tokens if provided $ghToken = (string) $this->getArgument('--github-token'); $giteaToken = (string) $this->getArgument('--gitea-token'); if ($ghToken !== '') { $config->set('github.token', $ghToken); } if ($giteaToken !== '') { $config->set('gitea.token', $giteaToken); } // Create both adapters try { $adapters = PlatformAdapterFactory::createBoth($config); $this->github = $adapters['github']; $this->gitea = $adapters['gitea']; } catch (\RuntimeException $e) { $this->log('ERROR', $e->getMessage()); return 1; } $this->checkpoints = new CheckpointManager('.checkpoints/migration'); $org = $config->getString('github.organization', 'MokoConsulting'); $giteaOrg = $config->getString('gitea.organization', 'MokoConsulting'); echo "=== Gitea Migration Tool ===\n"; echo "Source: GitHub ({$org})\n"; echo "Destination: Gitea ({$giteaOrg}) at " . $config->getString('gitea.url') . "\n"; echo "Mode: " . ($dryRun ? 'DRY RUN' : 'LIVE') . "\n\n"; // ── Phase 1: Discovery ────────────────────────────────────────── $this->section('Phase 1: Discovery'); $ghRepos = $this->github->listOrgRepos($org, $skipArchived); echo "Found " . count($ghRepos) . " repositories on GitHub\n"; // Filter repos if (!empty($specificRepos)) { $ghRepos = array_filter($ghRepos, fn($r) => in_array($r['name'], $specificRepos, true)); } if (!empty($excludeRepos)) { $ghRepos = array_filter($ghRepos, fn($r) => !in_array($r['name'], $excludeRepos, true)); } // Check which already exist on Gitea $giteaRepos = []; try { $existing = $this->gitea->listOrgRepos($giteaOrg); foreach ($existing as $r) { $giteaRepos[$r['name']] = true; } } catch (\Exception $e) { echo "Note: Could not list Gitea repos (org may not exist yet): {$e->getMessage()}\n"; } $toMigrate = []; $toSkip = []; foreach ($ghRepos as $repo) { $name = $repo['name']; if (isset($giteaRepos[$name])) { $toSkip[] = $name; } else { $toMigrate[] = $repo; } } echo "\nMigration plan:\n"; echo " Migrate: " . count($toMigrate) . " repositories\n"; echo " Skip: " . count($toSkip) . " (already on Gitea)\n"; if (!empty($toSkip)) { echo " Skipped: " . implode(', ', $toSkip) . "\n"; } echo "\n"; if (empty($toMigrate)) { echo "Nothing to migrate.\n"; return 0; } if ($dryRun) { echo "Repositories to migrate:\n"; foreach ($toMigrate as $repo) { $vis = $repo['private'] ? 'private' : 'public'; echo " - {$repo['name']} ({$vis})\n"; } echo "\nDry run complete. Use without --dry-run to execute.\n"; return 0; } // ── Phase 2: Migrate ──────────────────────────────────────────── $this->section('Phase 2: Migration'); $ghToken = $config->getString('github.token'); $results = ['migrated' => [], 'failed' => [], 'skipped' => $toSkip]; // Resume support $checkpoint = $resume ? $this->checkpoints->loadCheckpoint('gitea_migration') : null; $startFrom = $checkpoint['last_completed'] ?? ''; $skipUntil = !empty($startFrom); foreach ($toMigrate as $index => $repo) { $name = $repo['name']; if ($skipUntil) { if ($name === $startFrom) { $skipUntil = false; } echo " Skipping {$name} (already migrated)\n"; continue; } echo "\n [{$index}/{" . count($toMigrate) . "}] Migrating {$name}...\n"; try { // Shallow migration — copy current branch state only, no past // commit history. This gives every repo a clean start on Gitea. $this->gitea->migrateRepository([ 'clone_addr' => "https://github.com/{$org}/{$name}.git", 'repo_name' => $name, 'repo_owner' => $giteaOrg, 'service' => 'github', 'auth_token' => $ghToken, 'mirror' => false, 'private' => $repo['private'], 'issues' => false, 'labels' => true, 'milestones' => false, 'releases' => false, 'pull_requests' => false, 'wiki' => false, ]); echo " Migrated successfully\n"; $results['migrated'][] = $name; // Save checkpoint after each successful migration $this->checkpoints->saveCheckpoint('gitea_migration', [ 'last_completed' => $name, 'migrated' => $results['migrated'], 'failed' => $results['failed'], ]); } catch (\Exception $e) { echo " FAILED: " . $e->getMessage() . "\n"; $results['failed'][] = ['name' => $name, 'error' => $e->getMessage()]; $this->gitea->getApiClient()->resetCircuitBreaker(); } } // ── Phase 3: Post-migration ───────────────────────────────────── $this->section('Phase 3: Post-migration'); foreach ($results['migrated'] as $name) { echo " Post-processing {$name}...\n"; try { // Apply topics from GitHub $ghTopics = $this->github->getRepoTopics($org, $name); if (!empty($ghTopics)) { $this->gitea->setRepoTopics($giteaOrg, $name, $ghTopics); echo " Topics applied\n"; } // Apply branch protection $this->gitea->setBranchProtection($giteaOrg, $name, 'main', [ 'required_reviews' => 1, 'dismiss_stale' => true, 'block_on_rejected' => true, ]); echo " Branch protection applied\n"; } catch (\Exception $e) { echo " Warning: post-processing issue: " . $e->getMessage() . "\n"; $this->gitea->getApiClient()->resetCircuitBreaker(); } } // ── Phase 4: Verification ─────────────────────────────────────── $this->section('Phase 4: Verification'); $report = "## Migration Report\n\n"; $report .= "**Date:** " . gmdate('Y-m-d H:i:s') . " UTC\n"; $report .= "**Source:** GitHub ({$org})\n"; $report .= "**Destination:** Gitea ({$giteaOrg})\n\n"; $report .= "### Results\n\n"; $report .= "| Status | Count |\n|--------|-------|\n"; $report .= "| Migrated | " . count($results['migrated']) . " |\n"; $report .= "| Failed | " . count($results['failed']) . " |\n"; $report .= "| Skipped (existing) | " . count($results['skipped']) . " |\n\n"; if (!empty($results['migrated'])) { $report .= "### Migrated Repositories\n\n"; foreach ($results['migrated'] as $name) { $report .= "- {$name}\n"; } $report .= "\n"; } if (!empty($results['failed'])) { $report .= "### Failed Repositories\n\n"; foreach ($results['failed'] as $fail) { $report .= "- **{$fail['name']}**: {$fail['error']}\n"; } $report .= "\n"; } echo $report; // Create summary issue on Gitea try { $this->gitea->createIssue( $giteaOrg, 'MokoStandards', 'chore: GitHub → Gitea migration report — ' . count($results['migrated']) . ' repos migrated', $report, ['labels' => ['automation', 'type: chore']] ); echo "Migration report issue created on Gitea.\n"; } catch (\Exception $e) { echo "Could not create report issue: " . $e->getMessage() . "\n"; } echo "\nMigration complete: " . count($results['migrated']) . " migrated, " . count($results['failed']) . " failed, " . count($results['skipped']) . " skipped\n"; return count($results['failed']) > 0 ? 1 : 0; } } $script = new MigrateToGitea('migrate_to_gitea', 'Migrate repositories from GitHub to Gitea'); exit($script->execute());