diff --git a/commands/Site_Remove_Atlantis_Legacy_Modules.php b/commands/Site_Remove_Atlantis_Legacy_Modules.php new file mode 100644 index 0000000..21f0a80 --- /dev/null +++ b/commands/Site_Remove_Atlantis_Legacy_Modules.php @@ -0,0 +1,1734 @@ +setDescription( 'Removes Atlantis legacy modules from a Pressable or Atomic site.' ) + ->setHelp( 'Use this command to remove Atlantis legacy modules from a given Pressable or Atomic site, or from multiple sites using a CSV file.' ); + + $this->addArgument( 'site', InputArgument::OPTIONAL, 'The site to get repository information for.' ) + ->addOption( 'host', null, InputOption::VALUE_REQUIRED, 'The hosting provider (pressable or atomic).' ) + ->addOption( 'branch', null, InputOption::VALUE_REQUIRED, 'The branch to deploy from.', 'develop' ) + ->addOption( 'no-output', null, InputOption::VALUE_NONE, 'Skip confirmations and minimize output (except PR creation).' ) + ->addOption( 'merge-pr', null, InputOption::VALUE_NONE, 'Automatically merge the PR without asking for confirmation.' ) + ->addOption( 'uninstall', null, InputOption::VALUE_NONE, 'Uninstall plugins from WordPress in addition to removing them from the repository.' ) + ->addOption( 'sites', null, InputOption::VALUE_REQUIRED, 'Path to a CSV file containing sites to process (Site,URL,Host,Merged,PR).' ); + } + + /** + * {@inheritDoc} + */ + protected function initialize( InputInterface $input, OutputInterface $output ): void { + // Check if processing multiple sites from CSV. + $this->sites_csv_path = $input->getOption( 'sites' ); + + if ( $this->sites_csv_path ) { + // Get options for CSV processing. + $this->quiet = (bool) $input->getOption( 'no-output' ); + $this->merge_pr = (bool) $input->getOption( 'merge-pr' ); + $this->uninstall_plugins = (bool) $input->getOption( 'uninstall' ); + + // Validate CSV file exists. + if ( ! file_exists( $this->sites_csv_path ) ) { + $output->writeln( "CSV file not found: {$this->sites_csv_path}" ); + exit( 1 ); + } + + // Skip individual site initialization. + return; + } + + // Get no-output, merge-pr, and uninstall options. + $this->quiet = (bool) $input->getOption( 'no-output' ); + $this->merge_pr = (bool) $input->getOption( 'merge-pr' ); + $this->uninstall_plugins = (bool) $input->getOption( 'uninstall' ); + + // Get the site argument. If not provided, prompt for it (unless in quiet mode). + $site_input = $input->getArgument( 'site' ); + if ( empty( $site_input ) ) { + if ( $this->quiet ) { + $output->writeln( 'Site argument is required in quiet mode.' ); + exit( 1 ); + } + $site_input = $this->prompt_site_input( $input, $output ); + $input->setArgument( 'site', $site_input ); + } + + // Get and validate the host option. + $this->host = get_enum_input( $input, 'host', array( 'pressable', 'atomic' ), fn() => $this->prompt_host_input( $input, $output ) ); + $input->setOption( 'host', $this->host ); + + // Initialize site. + try { + $this->initialize_site_from_url( $site_input, $this->host ); + + // For WPCOM sites, normalize the URL property. + if ( 'atomic' === $this->host && isset( $this->site->URL ) ) { + $this->site->url = $this->site->URL; + } + + $input->setArgument( 'site', $this->site ); + } catch ( \Exception $e ) { + $output->writeln( "Failed to find the site with input: $site_input" ); + exit( 1 ); + } + + // Initialize repository. + try { + $this->initialize_repository( $input, $output ); + + // Show DeployHQ info for Pressable sites. + if ( 'pressable' === $this->host && $this->deployhq_project ) { + $output->writeln( "Found DeployHQ project {$this->deployhq_project->name} (permalink {$this->deployhq_project->permalink}) for the given site.", OutputInterface::VERBOSITY_VERBOSE ); + } + } catch ( \Exception $e ) { + $output->writeln( 'Failed to get the GitHub repository connected to the project or invalid connected repository.' ); + exit( 1 ); + } + + // Initialize repository paths. + $this->repos_dir = getcwd() . '/repos'; + $this->repo_dir = $this->repos_dir . '/' . $this->gh_repository->name; + + // Check if the site is a staging site and set the base branch accordingly. + $environment = $this->get_site_environment( $output ); + if ( 'production' === $environment ) { + $this->git_base_branch = 'trunk'; + } + } + + /** + * {@inheritDoc} + */ + protected function interact( InputInterface $input, OutputInterface $output ): void { + // Skip interaction if quiet mode is enabled or processing CSV. + if ( $this->quiet || $this->sites_csv_path ) { + return; + } + + $question = new ConfirmationQuestion( "Are you sure you want to remove legacy modules from the repository for {$this->site->url} [{$this->host}] (repository: {$this->gh_repository->name} [{$this->git_base_branch}])? [y/N] ", false ); + if ( true !== $this->getHelper( 'question' )->ask( $input, $output, $question ) ) { + $output->writeln( 'Command aborted by user.' ); + exit( 2 ); + } + } + + /** + * {@inheritDoc} + */ + protected function execute( InputInterface $input, OutputInterface $output ): int { + // If CSV file provided, process multiple sites. + if ( $this->sites_csv_path ) { + return $this->process_csv( $input, $output ); + } + + // Otherwise, process single site. + return $this->process_single_site( $input, $output ); + } + + // endregion + + // region CSV PROCESSING + + /** + * Process multiple sites from a CSV file. + * + * @param InputInterface $input The input object. + * @param OutputInterface $output The output object. + * + * @return int + */ + private function process_csv( InputInterface $input, OutputInterface $output ): int { + $output->writeln( 'Processing sites from CSV file...' ); + $output->writeln( '' ); + + // Read CSV file. + $csv_data = $this->read_csv( $this->sites_csv_path ); + if ( empty( $csv_data ) ) { + $output->writeln( 'No valid sites found in CSV file.' ); + return Command::FAILURE; + } + + // Validate required columns exist in first row. + $first_row = reset( $csv_data ); + $required_columns = array( 'Site', 'URL', 'Host' ); + foreach ( $required_columns as $column ) { + if ( ! isset( $first_row[ $column ] ) ) { + $output->writeln( "CSV file is missing required column: {$column}" ); + return Command::FAILURE; + } + } + + $total_sites = count( $csv_data ); + $processed = 0; + $skipped = 0; + $failed = 0; + $counter = 0; + + foreach ( $csv_data as $index => $site_data ) { + ++$counter; + $site_name = $site_data['Site'] ?? 'Unknown'; + $site_url = $site_data['URL'] ?? ''; + $host = strtolower( $site_data['Host'] ?? '' ); + + // Validate required fields. + if ( empty( $site_url ) || empty( $host ) ) { + $note = 'Missing required fields (URL or Host)'; + $this->update_csv_row( $index, '', '', $note ); + $output->writeln( "[{$counter}/{$total_sites}] Skipping {$site_name} - {$note}" ); + ++$skipped; + continue; + } + + // Validate host value. + if ( ! in_array( $host, array( 'pressable', 'atomic' ), true ) ) { + $note = "Invalid host value: {$host} (must be 'pressable' or 'atomic')"; + $this->update_csv_row( $index, '', '', $note ); + $output->writeln( "[{$counter}/{$total_sites}] Skipping {$site_name} - {$note}" ); + ++$skipped; + continue; + } + + // Skip if already processed (has PR URL or is marked as Merged). + if ( ! empty( $site_data['PR'] ?? '' ) || 'Y' === strtoupper( $site_data['Merged'] ?? '' ) ) { + $note = 'Already processed (has PR or marked as Merged)'; + $this->update_csv_row( $index, $site_data['PR'] ?? '', $site_data['Merged'] ?? '', $note ); + $output->writeln( "[{$counter}/{$total_sites}] Skipping {$site_name} - already processed" ); + ++$skipped; + continue; + } + + $output->writeln( "[{$counter}/{$total_sites}] Processing: {$site_name} ({$site_url})" ); + + try { + // Reset state for this site. + $this->reset_site_state(); + $this->pr_url = null; + + // Initialize site data. + $this->host = $host; + $input->setOption( 'host', $host ); + + // Initialize site. + $this->initialize_site_from_url( $site_url, $host ); + + // Normalize URL property for WPCOM sites (they use uppercase URL). + if ( 'atomic' === $host && isset( $this->site->URL ) ) { + $this->site->url = $this->site->URL; + } + + // Safety check: Compare CSV URL with API URL (applies to both Atomic AND Pressable). + $csv_domain = $this->extract_domain_from_url( $site_url ); + $api_domain = $this->extract_domain_from_url( $this->site->url ?? '' ); + + if ( $csv_domain !== $api_domain ) { + $note = "API URL differs from CSV: {$this->site->url}"; + $this->update_csv_row( $index, '', '', $note ); + $output->writeln( "⚠ {$site_name}: {$note}" ); + ++$skipped; + continue; + } + + // Store CSV URL for environment detection (more reliable than API URL). + $this->csv_url = $site_url; + + // Initialize repository (handle deployment configuration errors gracefully). + try { + $this->initialize_repository( $input, $output ); + } catch ( \Exception $e ) { + // Check if it's a deployment configuration error (DeployHQ for Pressable, WPCOM GitHub Deployments for Atomic). + $is_deployment_error = str_contains( $e->getMessage(), 'WPCOM GitHub Deployments' ) + || str_contains( $e->getMessage(), 'GitHub repository' ) + || str_contains( $e->getMessage(), 'DeployHQ' ); + + if ( ! $is_deployment_error ) { + // Re-throw if it's a different error. + throw $e; + } + + $deployment_type = ( 'pressable' === $host ) ? 'DeployHQ' : 'WPCOM GitHub'; + $output->writeln( "⚠ {$site_name}: Unable to find a {$deployment_type} deployment for the site." ); + + // Present the user with choices for how to proceed. + $choices = array( + 'enter_repo' => 'Enter a repository URL or slug manually', + 'site_only' => 'Skip repository, process site only (install Atlantis, handle plugins)', + 'skip' => 'Skip this site entirely', + ); + + $question = new ChoiceQuestion( 'How would you like to proceed? ', $choices, 'skip' ); + $deployment_choice = $this->getHelper( 'question' )->ask( $input, $output, $question ); + + if ( 'skip' === $deployment_choice ) { + $note = "No {$deployment_type} deployment for the site - skipped by user"; + $this->update_csv_row( $index, '', '', $note ); + $output->writeln( "⚠ {$site_name}: {$note}" ); + ++$skipped; + continue; + } + + $this->deployment_not_found = true; + + if ( 'enter_repo' === $deployment_choice ) { + $repo = $this->prompt_and_resolve_repository( $input, $output ); + if ( null === $repo ) { + $note = "No {$deployment_type} deployment - manual repo entry failed"; + $this->update_csv_row( $index, '', '', $note ); + $output->writeln( "⚠ {$site_name}: {$note}" ); + ++$skipped; + continue; + } + $this->gh_repository = $repo; + } elseif ( 'site_only' === $deployment_choice ) { + $this->skip_repository = true; + } + } + + if ( ! $this->skip_repository ) { + $this->initialize_paths_and_branch( $input ); + + // Determine environment and base branch. + $environment = $this->get_site_environment( $output ); + if ( 'production' === $environment ) { + $this->git_base_branch = 'trunk'; + } + + // Safety check: If CSV URL indicates staging, never use trunk/master. + // This prevents accidentally processing production when staging was intended. + $csv_url_lower = strtolower( $site_url ); + $is_staging_url = str_contains( $csv_url_lower, 'staging' ) || + str_contains( $csv_url_lower, 'mystagingwebsite' ) || + str_contains( $csv_url_lower, 'wpcomstaging' ) || + str_contains( $csv_url_lower, '-dev' ) || + str_contains( $csv_url_lower, '-development' ); + + if ( $is_staging_url && in_array( $this->git_base_branch, array( 'trunk', 'master' ), true ) ) { + $note = "Staging URL but would use {$this->git_base_branch} branch - skipping for safety"; + $this->update_csv_row( $index, '', '', $note ); + $output->writeln( "⚠ {$site_name}: {$note}" ); + ++$skipped; + continue; + } + } + + // Process the site. + $result = $this->process_single_site( $input, $output ); + + if ( Command::INVALID === $result ) { + // Site was skipped due to PHP version or branch issue. + $note = $this->skip_note ?? 'Site skipped (unknown reason)'; + $this->update_csv_row( $index, '', '', $note ); + $output->writeln( "⚠ {$site_name}: {$note}" ); + ++$skipped; + } elseif ( Command::SUCCESS === $result ) { + if ( $this->skip_repository ) { + // Site-only processing: Atlantis installed and plugins handled, no repo operations. + $note = $this->skip_note + ? "Site-only: Atlantis installed, {$this->skip_note}" + : 'Site-only: Atlantis installed, no deployment found (repo skipped)'; + $this->update_csv_row( $index, '', 'Y', $note ); + $output->writeln( "✓ {$site_name} completed (site-only, no repository)" ); + } elseif ( $this->pr_url ) { + // Update CSV with PR URL and Merged status. + // Mark as 'Y' only if --merge-pr was used. + $merged_status = $this->merge_pr ? 'Y' : ''; + $this->update_csv_row( $index, $this->pr_url, $merged_status ); + $output->writeln( "✓ {$site_name} completed successfully" ); + } else { + // No PR created - plugin installed, no repo changes needed (no legacy modules in repo). + // This is still a successful completion. + $this->update_csv_row( $index, '', 'Y', 'Plugin installed (no repo changes needed)' ); + $output->writeln( "✓ {$site_name} completed successfully (no repo changes needed)" ); + } + ++$processed; + } else { + $note = $this->skip_note ?? 'Processing failed'; + $this->update_csv_row( $index, '', '', $note ); + ++$failed; + $output->writeln( "✗ {$site_name} failed: {$note}" ); + } + } catch ( \Exception $e ) { + $note = 'Error: ' . $e->getMessage(); + $this->update_csv_row( $index, '', '', $note ); + ++$failed; + $output->writeln( "✗ {$site_name} failed: {$e->getMessage()}" ); + } + + $output->writeln( '' ); + } + + // Summary. + $output->writeln( '' ); + $output->writeln( '=== Processing Summary ===' ); + $output->writeln( "Total sites: {$total_sites}" ); + $output->writeln( "Processed: {$processed}" ); + $output->writeln( "Skipped: {$skipped}" ); + if ( $failed > 0 ) { + $output->writeln( "Failed: {$failed}" ); + } + + return Command::SUCCESS; + } + + /** + * Process a single site. + * + * @param InputInterface $input The input object. + * @param OutputInterface $output The output object. + * + * @return int + */ + private function process_single_site( InputInterface $input, OutputInterface $output ): int { + if ( $this->skip_repository ) { + $this->write_output( $output, "Processing site-only operations for {$this->site->url} (no repository)." ); + } else { + $this->write_output( $output, "Processing repository for site {$this->site->url} (base branch: {$this->git_base_branch})." ); + } + + // Check PHP version first - Atlantis plugin requires PHP 8.3+. + $this->check_php_version( $output ); + if ( null === $this->php_version ) { + $this->skip_note = 'Unable to connect to site (SSH may be disabled)'; + $this->write_output( $output, "{$this->skip_note}. Skipping site." ); + return Command::INVALID; + } + if ( ! $this->meets_php_requirement() ) { + $this->skip_note = "PHP version {$this->php_version} < {$this->min_php_version} required"; + $this->write_output( $output, "{$this->skip_note}. Skipping Atlantis plugin installation." ); + // Return a special status to indicate PHP version issue (caller will handle CSV update). + return Command::INVALID; + } + + $found_modules = array(); + + if ( ! $this->skip_repository ) { + $this->clone_repo( $output ); + + // Checkout the base branch - handle failure gracefully. + $checkout_process = \run_system_command( array( 'git', 'checkout', $this->git_base_branch ), $this->repo_dir, false ); + if ( ! $checkout_process->isSuccessful() ) { + // Clean up the cloned repo folder. + if ( $this->repo_dir && $this->repos_dir && str_starts_with( $this->repo_dir, $this->repos_dir ) ) { + \run_system_command( array( 'rm', '-rf', $this->repo_dir ), $this->repo_dir, false ); + } + + if ( $this->deployment_not_found ) { + // Repo was manually entered due to missing deployment — fall back to site-only. + $this->skip_repository = true; + $this->skip_note = "Repo specified but branch '{$this->git_base_branch}' not found"; + $this->write_output( $output, "{$this->skip_note}. Continuing with site-only operations." ); + } else { + $this->skip_note = "Branch '{$this->git_base_branch}' not found"; + $this->write_output( $output, "{$this->skip_note}. Skipping site." ); + return Command::INVALID; + } + } + + if ( ! $this->skip_repository ) { + // Checkout the branch 'remove/atlantis-legacy-modules'. + $this->git_checkout_branch( 'remove/atlantis-legacy-modules', $output ); + + // Check for legacy modules and delete them if they exist. + $this->write_output( $output, '' ); + $this->write_output( $output, 'Searching for legacy modules to remove...' ); + + // First, find which legacy modules exist. + foreach ( $this->legacy_modules as $plugin_name ) { + $plugin_info = $this->find_plugin( $plugin_name, $output ); + if ( null !== $plugin_info ) { + $found_modules[ $plugin_name ] = $plugin_info; + } + } + } + } + + // Check autoupdate filter status BEFORE installing Atlantis. + // This must happen first so the database option is set before Atlantis activates. + $this->check_autoupdate_filter_status( $output ); + + // Always install the Atlantis plugin. + $plugin_installed = $this->install_atlantis_plugin( $output ); + + if ( ! $plugin_installed ) { + $this->skip_note = 'Atlantis plugin installation failed (site has a critical error)'; + return Command::FAILURE; + } + + if ( ! $this->skip_repository && $plugin_installed && ! empty( $found_modules ) ) { + // Delete the found modules from the repository. + foreach ( $found_modules as $plugin_name => $plugin_info ) { + $this->delete_plugin( $plugin_name, $plugin_info['location'], $plugin_info['path'], $output ); + } + } + + // Uninstall plugins from WordPress if --uninstall option is provided. + // No confirmation needed - just proceed automatically. + $legacy_modules_count = count( $this->legacy_modules ); + if ( $this->uninstall_plugins && $legacy_modules_count > 0 ) { + $this->uninstall_plugins_from_wordpress( $output ); + } + + if ( ! $this->skip_repository && count( $this->removed_modules ) > 0 ) { + $this->stage_commit_push_pr_and_merge( $input, $output ); + + // Delete the repository folder (with safety check). + if ( $this->repo_dir && $this->repos_dir && str_starts_with( $this->repo_dir, $this->repos_dir ) ) { + \run_system_command( array( 'rm', '-rf', $this->repo_dir ), $this->repo_dir, false ); + $this->write_output( $output, "Deleted repository folder: {$this->repo_dir}" ); + } else { + $this->write_output( $output, 'Safety check failed: repository path is not within repos directory. Skipping deletion.' ); + } + } + + return Command::SUCCESS; + } + + // endregion + + // region HELPERS + + /** + * Writes output to the console unless in quiet mode. + * + * @param OutputInterface $output The output object. + * @param string|array $message The message(s) to write. + * @param int $options Output options. + * + * @return void + */ + private function write_output( OutputInterface $output, $message, int $options = 0 ): void { + if ( ! $this->quiet ) { + $output->writeln( $message, $options ); + } + } + + /** + * Extract domain from URL (remove protocol, path, etc.). + * + * @param string $url The URL to parse. + * + * @return string The domain only. + */ + private function extract_domain_from_url( string $url ): string { + if ( empty( $url ) ) { + return ''; + } + + // Remove protocol (http://, https://) + $domain = preg_replace( '#^https?://#i', '', $url ); + + // Remove path, query string, and fragment + // Use a different delimiter (e.g., ~) to avoid conflicts with '#' + $domain = preg_replace( '~[/?#].*$~', '', $domain ); + + // Remove trailing slash if any + $domain = rtrim( $domain ?? '', '/' ); + + return $domain; + } + + /** + * Initialize site from URL and host. + * + * @param string $site_url The site URL. + * @param string $host The hosting provider ('pressable' or 'atomic'). + * + * @throws \Exception If site cannot be found. + * @return void + */ + private function initialize_site_from_url( string $site_url, string $host ): void { + // Extract domain only (remove protocol, path, etc.). + $domain = $this->extract_domain_from_url( $site_url ); + + if ( 'pressable' === $host ) { + $this->site = get_pressable_site( $domain ); + } else { + $this->site = get_wpcom_site( $domain ); + } + + if ( ! $this->site ) { + throw new \Exception( "Could not find site: {$domain}" ); + } + } + + /** + * Initialize GitHub repository based on host. + * + * @param InputInterface $input The input object. + * @param OutputInterface $output The output object. + * + * @throws \Exception If repository cannot be found. + * @return void + */ + private function initialize_repository( InputInterface $input, OutputInterface $output ): void { + if ( 'pressable' === $this->host ) { + // Get Pressable DeployHQ config and GitHub repository. + $deployhq_config = get_pressable_site_deployhq_config( $this->site->id ); + if ( $deployhq_config ) { + $this->deployhq_project = $deployhq_config->project; + $this->gh_repository = get_github_repository_from_deployhq_project( $this->deployhq_project->permalink ); + } + } else { + // Get WPCOM repository. + // Check for WPCOM GitHub Deployments first. + $wpcom_gh_repositories = get_wpcom_site_code_deployments( $this->site->ID ); + + if ( empty( $wpcom_gh_repositories ) ) { + // In CSV processing mode, throw exception instead of asking. + if ( $this->sites_csv_path ) { + throw new \Exception( 'Unable to find a WPCOM GitHub Deployments for the site.' ); + } + + // In interactive mode, ask user. + $output->writeln( 'Unable to find a WPCOM GitHub Deployments for the site.' ); + $question = new ConfirmationQuestion( 'Do you want to continue anyway? [y/N] ', false ); + if ( true !== $this->getHelper( 'question' )->ask( $input, $output, $question ) ) { + $output->writeln( 'Command aborted by user.' ); + exit( 1 ); + } + return; // Exit early if user chooses to continue without repository. + } + + // Continue with normal WPCOM repository initialization. + $this->get_wpcom_repository( $input, $output ); + } + + // Validate repository was found. + if ( ! $this->gh_repository || ! $this->gh_repository->name ) { + throw new \Exception( 'Could not find GitHub repository for site: ' . ( $this->site->url ?? 'unknown' ) ); + } + } + + /** + * Initialize repository paths and branch. + * + * @param InputInterface $input The input object. + * + * @return void + */ + private function initialize_paths_and_branch( InputInterface $input ): void { + // Set up repository paths. + $this->repos_dir = getcwd() . '/repos'; + $this->repo_dir = $this->repos_dir . '/' . $this->gh_repository->name; + + // Set default branch. + $this->gh_repo_branch = 'develop'; + $input->setOption( 'branch', $this->gh_repo_branch ); + } + + /** + * Prompts the user for a hosting provider. + * + * @param InputInterface $input The input object. + * @param OutputInterface $output The output object. + * + * @return string|null + */ + private function prompt_host_input( InputInterface $input, OutputInterface $output ): ?string { + $choices = array( + 'pressable' => 'Pressable', + 'atomic' => 'Atomic', + ); + + $question = new ChoiceQuestion( 'Please select the hosting provider: ', $choices, 'pressable' ); + $question->setValidator( fn( $value ) => validate_user_choice( $value, $choices ) ); + return $this->getHelper( 'question' )->ask( $input, $output, $question ); + } + + /** + * Prompts the user for a site. + * + * @param InputInterface $input The input object. + * @param OutputInterface $output The output object. + * + * @return string|null + */ + private function prompt_site_input( InputInterface $input, OutputInterface $output ): ?string { + $question = new Question( 'Enter the domain or site ID: ' ); + return $this->getHelper( 'question' )->ask( $input, $output, $question ); + } + + /** + * Prompts the user for a branch name. + * + * @param InputInterface $input The input object. + * @param OutputInterface $output The output object. + * + * @return string|null + */ + private function prompt_branch_input( InputInterface $input, OutputInterface $output ): ?string { + $question = new Question( 'Enter the branch to deploy from [develop]: ', 'develop' ); + if ( ! $input->getOption( 'no-autocomplete' ) ) { + $question->setAutocompleterValues( array_column( get_github_repository_branches( $this->gh_repository->name ) ?? array(), 'name' ) ); + } + + return $this->getHelper( 'question' )->ask( $input, $output, $question ); + } + + /** + * Prompts the user for a GitHub repository URL or slug and resolves it via the GitHub API. + * + * Accepts a plain slug ("my-repo"), org/slug ("a8cteam51/my-repo"), + * a full HTTPS URL ("https://github.com/a8cteam51/my-repo"), or + * an SSH clone URL ("git@github.com:a8cteam51/my-repo.git"). + * + * @param InputInterface $input The input object. + * @param OutputInterface $output The output object. + * + * @return \stdClass|null The resolved GitHub repository object, or null on failure. + */ + private function prompt_and_resolve_repository( InputInterface $input, OutputInterface $output ): ?\stdClass { + $question = new Question( 'Enter the GitHub repository URL or slug (e.g. "my-repo" or "https://github.com/a8cteam51/my-repo"): ' ); + $repo_input = $this->getHelper( 'question' )->ask( $input, $output, $question ); + + if ( empty( $repo_input ) ) { + $output->writeln( 'No repository provided.' ); + return null; + } + + $repo_input = trim( $repo_input ); + + // Try to parse as a full URL (HTTPS or SSH). + $git_url = str_ends_with( $repo_input, '.git' ) ? $repo_input : $repo_input . '.git'; + $parsed = parse_github_remote_repository_url( $git_url ); + + if ( null !== $parsed && ! empty( $parsed->repo ) ) { + $repo_slug = $parsed->repo; + } elseif ( str_contains( $repo_input, '/' ) ) { + // Handle "org/repo" format — extract the repo part. + $parts = explode( '/', rtrim( $repo_input, '/' ) ); + $repo_slug = end( $parts ); + } else { + // Plain slug. + $repo_slug = $repo_input; + } + + $output->writeln( "Resolving repository: {$repo_slug}..." ); + + $repository = get_github_repository( $repo_slug ); + if ( null === $repository || empty( $repository->name ) ) { + $output->writeln( "Could not find GitHub repository: {$repo_slug}" ); + return null; + } + + $output->writeln( "Found repository: {$repository->name} ({$repository->clone_url})" ); + return $repository; + } + + /** + * Gets the repository slug from the owner/repo-slug name. + * + * @param string $repository_name The repository name. + * + * @return string|null + */ + private function get_repository_slug_from_repository_name( string $repository_name ): ?string { + $repository_parts = explode( '/', $repository_name ); + + return $repository_parts[1] ?? null; + } + + /** + * Gets the site environment. + * + * @param OutputInterface $output The output object. + * + * @return string + */ + private function get_site_environment( OutputInterface $output ): string { + // Use stored CSV URL if available (more reliable for staging detection). + $url_to_check = $this->csv_url ?? $this->site->url ?? ''; + + // If the URL contains 'mystagingwebsite' or 'wpcomstaging' it's a staging site. Otherwise it's a production site. + if ( str_contains( $url_to_check, 'mystagingwebsite' ) || str_contains( $url_to_check, 'wpcomstaging' ) ) { + $this->write_output( $output, 'Site is a staging site.' ); + return 'staging'; + } + $this->write_output( $output, 'Site is a production site.' ); + return 'production'; + } + + /** + * Clones the GitHub repository. + * + * @param OutputInterface $output The output object. + * + * @return void + */ + private function clone_repo( OutputInterface $output ): void { + // Create a folder named "repos" in the current working directory if it doesn't exist. + if ( ! file_exists( $this->repos_dir ) ) { + mkdir( $this->repos_dir, 0755, true ); + } + + // Create a folder named the repository name in the repos folder if it doesn't exist. + if ( ! file_exists( $this->repo_dir ) ) { + mkdir( $this->repo_dir, 0755, true ); + $this->write_output( $output, "Created folder: {$this->repo_dir}" ); + + // Clone the repository. + \run_system_command( array( 'git', 'clone', $this->gh_repository->clone_url, $this->repo_dir ) ); + $this->write_output( $output, "Cloned repository: {$this->gh_repository->name} ({$this->gh_repository->clone_url}) into {$this->repo_dir}" ); + } + } + + /** + * Gets the WPCOM repository. + * + * @param InputInterface $input The input object. + * @param OutputInterface $output The output object. + * + * @return void + */ + private function get_wpcom_repository( InputInterface $input, OutputInterface $output ): void { + $wpcom_gh_repositories = get_wpcom_site_code_deployments( $this->site->ID ); + $gh_repository_name = null; + + if ( empty( $wpcom_gh_repositories ) ) { + $output->writeln( 'Unable to find a WPCOM GitHub Deployments for the site.' ); + + $question = new ConfirmationQuestion( 'Do you want to continue anyway? [y/N] ', false ); + if ( true !== $this->getHelper( 'question' )->ask( $input, $output, $question ) ) { + $output->writeln( 'Command aborted by user.' ); + exit( 1 ); + } + } + + if ( $wpcom_gh_repositories && 1 < count( $wpcom_gh_repositories ) ) { + $output->writeln( 'Found multiple WPCOM GitHub Deployments for the site.' ); + + $question = new ChoiceQuestion( + 'Choose from which repository you want to use: ', + \array_column( $wpcom_gh_repositories, 'repository_name' ), + 0 + ); + $question->setErrorMessage( 'Repository %s is invalid.' ); + + $gh_repository_name = $this->get_repository_slug_from_repository_name( $this->getHelper( 'question' )->ask( $input, $output, $question ) ); + } elseif ( $wpcom_gh_repositories && 1 === count( $wpcom_gh_repositories ) ) { + $gh_repository_name = $this->get_repository_slug_from_repository_name( $wpcom_gh_repositories[0]->repository_name ); + } + + if ( $gh_repository_name ) { + $this->gh_repo_branch = get_string_input( $input, 'branch', fn() => $this->prompt_branch_input( $input, $output ) ); + $input->setOption( 'branch', $this->gh_repo_branch ); + + $this->gh_repository = get_github_repository( $gh_repository_name ); + } + } + + /** + * Checks out the provided branch in the repository. + * Creates the branch if it doesn't exist, otherwise checks out the existing branch. + * + * @param string $branch The branch to checkout. + * @param OutputInterface $output The output object. + * + * @return void + */ + private function git_checkout_branch( string $branch, OutputInterface $output ): void { + // First, check if the branch exists locally using git show-ref. + $check_process = new \Symfony\Component\Process\Process( + array( 'git', 'show-ref', '--verify', '--quiet', "refs/heads/{$branch}" ), + $this->repo_dir + ); + $check_process->run(); + + if ( 0 === $check_process->getExitCode() ) { + // Branch exists, just checkout. + $this->write_output( $output, " Branch {$branch} exists. Checking out..." ); + \run_system_command( array( 'git', 'checkout', $branch ), $this->repo_dir ); + $this->write_output( $output, "Checked out existing branch: {$branch}" ); + } else { + // Branch doesn't exist, create it from current HEAD. + $this->write_output( $output, " Branch {$branch} doesn't exist. Creating from {$this->git_base_branch}..." ); + $process = \run_system_command( array( 'git', 'checkout', '-b', $branch ), $this->repo_dir ); + + if ( $process->getExitCode() !== 0 ) { + $output->writeln( "Failed to create and checkout branch: {$branch}" ); + exit( 1 ); + } + $this->write_output( $output, "Created and checked out branch: {$branch}" ); + } + } + + /** + * Stages, commits, pushes, creates a PR and merges the changes. + * + * @param InputInterface $input The input object. + * @param OutputInterface $output The output object. + * + * @return void + */ + private function stage_commit_push_pr_and_merge( InputInterface $input, OutputInterface $output ): void { + // Stage file changes. + \run_system_command( array( 'git', 'add', '.' ), $this->repo_dir, false ); + $this->write_output( $output, 'Staged file changes.' ); + + // Commit file changes. + \run_system_command( array( 'git', 'commit', '-m', 'Remove legacy modules' ), $this->repo_dir, false ); + $this->write_output( $output, 'Committed file changes.' ); + + // Push the branch with changes to remote. + // Use --force-with-lease to handle the case where the branch already exists from a previous run. + // This is safe because it's a temporary branch that will be deleted after merge. + \run_system_command( array( 'git', 'push', '--force-with-lease', '--set-upstream', 'origin', 'remove/atlantis-legacy-modules' ), $this->repo_dir, false ); + $this->write_output( $output, "Pushed branch 'remove/atlantis-legacy-modules' to remote." ); + + // Create PR from remove/atlantis-legacy-modules to the base branch. + // Always show PR creation output, even in silent mode. + $this->write_output( $output, "Creating PR to merge remove/atlantis-legacy-modules into {$this->git_base_branch}...", OutputInterface::VERBOSITY_QUIET ); + + // Try to create the PR. If it already exists, gh will output the existing PR URL. + $process = \run_system_command( + array( + 'gh', + 'pr', + 'create', + '--title', + 'Atlantis Rollout - Remove legacy modules', + '--body', + 'This PR was automatically generated by a script. Please review the changes and merge if they look good.', + '--base', + $this->git_base_branch, + '--head', + 'remove/atlantis-legacy-modules', + '--repo', + 'a8cteam51/' . $this->gh_repository->name, + ), + $this->repo_dir, + false // Don't exit on error - the PR might already exist. + ); + + // Output the result (either new PR URL or error message). + $pr_output = trim( $process->getOutput() . $process->getErrorOutput() ); + if ( ! empty( $pr_output ) ) { + $output->writeln( $pr_output, OutputInterface::VERBOSITY_QUIET ); + + // Extract and save PR URL for CSV processing. + // The output typically contains the PR URL (e.g., https://github.com/a8cteam51/repo/pull/123). + if ( preg_match( '#https://github\.com/[^/]+/[^/]+/pull/\d+#', $pr_output, $matches ) ) { + $this->pr_url = $matches[0]; + } + } + + // Ask for confirmation before merging PR and deleting branches (unless merge-pr or quiet flag is set). + $should_merge = $this->merge_pr; + if ( ! $this->quiet && ! $this->merge_pr ) { + $output->writeln( '' ); + $question = new ConfirmationQuestion( 'Do you want to merge the PR and delete the branches? [y/N] ', false ); + if ( true !== $this->getHelper( 'question' )->ask( $input, $output, $question ) ) { + $output->writeln( 'Skipping PR merge and branch deletion.' ); + return; + } + $should_merge = true; + } + + if ( $should_merge ) { + // Merge the PR - always show output, even in silent mode. + $output->writeln( 'Merging PR...', OutputInterface::VERBOSITY_QUIET ); + + // Try with --auto first (for PRs that need to wait for checks). + // Use Process directly to avoid printing the error when it's expected. + $process = new \Symfony\Component\Process\Process( + array( + 'gh', + 'pr', + 'merge', + 'remove/atlantis-legacy-modules', + '--squash', // Squash all commits into one + '--auto', // Auto-merge when requirements are met + '--delete-branch', // Delete the branch after merge + '--repo', + 'a8cteam51/' . $this->gh_repository->name, + ), + $this->repo_dir + ); + $process->run(); + + // Check if auto-merge failed and we should retry without --auto. + // This happens when: + // - PR is already in clean status (ready to merge immediately) + // - Branch protection rules not configured (auto-merge not available) + $error_output = $process->getErrorOutput(); + $should_retry = str_contains( $error_output, 'is in clean status' ) || + str_contains( $error_output, 'Protected branch rules not configured' ) || + str_contains( $error_output, 'enablePullRequestAutoMerge' ); + + if ( 0 !== $process->getExitCode() && $should_retry ) { + // Retry without --auto flag. + $output->writeln( 'Auto-merge not available, merging directly...', OutputInterface::VERBOSITY_QUIET ); + $process = \run_system_command( + array( + 'gh', + 'pr', + 'merge', + 'remove/atlantis-legacy-modules', + '--squash', + '--delete-branch', + '--repo', + 'a8cteam51/' . $this->gh_repository->name, + ), + $this->repo_dir, + false + ); + } elseif ( 0 !== $process->getExitCode() ) { + // Some other error occurred, output it and exit. + $output->writeln( 'Failed to merge PR:', OutputInterface::VERBOSITY_QUIET ); + $output->writeln( $error_output, OutputInterface::VERBOSITY_QUIET ); + exit( 1 ); + } + + // Output the merge result (only if successful). + if ( 0 === $process->getExitCode() ) { + $merge_output = trim( $process->getOutput() . $process->getErrorOutput() ); + if ( ! empty( $merge_output ) ) { + $output->writeln( $merge_output, OutputInterface::VERBOSITY_QUIET ); + } + } + } + } + + /** + * Finds a plugin folder in the repository. + * Checks mu-plugins first, then plugins. + * + * @param string $plugin_name The plugin folder name to search for. + * @param OutputInterface $output The output object. + * + * @return array|null Array with 'location' and 'path' keys, or null if not found. + */ + private function find_plugin( string $plugin_name, OutputInterface $output ): ?array { + // Check in mu-plugins folder first. + $mu_plugins_path = $this->repo_dir . '/mu-plugins/' . $plugin_name; + if ( file_exists( $mu_plugins_path ) ) { + $this->write_output( $output, "{$plugin_name} found in mu-plugins" ); + return array( + 'location' => 'mu-plugins', + 'path' => $mu_plugins_path, + ); + } + + // Check in plugins folder. + $plugins_path = $this->repo_dir . '/plugins/' . $plugin_name; + if ( file_exists( $plugins_path ) ) { + $this->write_output( $output, "{$plugin_name} found in plugins" ); + return array( + 'location' => 'plugins', + 'path' => $plugins_path, + ); + } + + $this->write_output( $output, "{$plugin_name} not found in mu-plugins or plugins directories." ); + return null; + } + + /** + * Deletes a plugin folder from the repository. + * + * @param string $plugin_name The plugin name. + * @param string $location The location type ('mu-plugins' or 'plugins'). + * @param string $plugin_path The path to the plugin folder. + * @param OutputInterface $output The output object. + * + * @return void + */ + private function delete_plugin( string $plugin_name, string $location, string $plugin_path, OutputInterface $output ): void { + if ( ! file_exists( $plugin_path ) ) { + return; + } + + $this->write_output( $output, "Removing {$plugin_name} from {$location}..." ); + + // Check if .gitmodules exists and contains the plugin as a submodule. + $gitmodules_path = $this->repo_dir . '/.gitmodules'; + $submodule_path = $location . '/' . $plugin_name; + $is_submodule = false; + + if ( file_exists( $gitmodules_path ) ) { + $gitmodules_content = file_get_contents( $gitmodules_path ); + + // Check if the plugin submodule exists in .gitmodules for this location. + if ( false !== $gitmodules_content && str_contains( $gitmodules_content, $submodule_path ) ) { + $is_submodule = true; + } + } + + if ( $is_submodule ) { + $this->write_output( $output, " → {$plugin_name} is a git submodule. Removing..." ); + + // Use git commands to properly remove the submodule. + // 1. Deinitialize the submodule. + $this->write_output( $output, ' 1. Deinitializing submodule...' ); + \run_system_command( array( 'git', 'submodule', 'deinit', '-f', $submodule_path ), $this->repo_dir, false ); + + // 2. Remove the submodule from git index. + $this->write_output( $output, ' 2. Removing submodule from git index...' ); + \run_system_command( array( 'git', 'rm', '-f', $submodule_path ), $this->repo_dir, false ); + + // 3. Remove the submodule's .git directory. + $submodule_git_dir = $this->repo_dir . '/.git/modules/' . $submodule_path; + if ( file_exists( $submodule_git_dir ) ) { + $this->write_output( $output, ' 3. Cleaning up .git/modules directory...' ); + \run_system_command( array( 'rm', '-rf', $submodule_git_dir ) ); + } + + $this->write_output( $output, " ✓ {$plugin_name} submodule successfully removed from {$location}" ); + } else { + $this->write_output( $output, " → {$plugin_name} is a regular directory. Removing..." ); + \run_system_command( array( 'rm', '-rf', $plugin_path ) ); + $this->write_output( $output, " ✓ {$plugin_name} directory successfully removed from {$location}" ); + } + + $this->removed_modules[] = $plugin_name; + } + + /** + * Installs and activates the Atlantis plugin from GitHub. + * + * @param OutputInterface $output The output object. + * + * @return bool True if plugin was installed and activated successfully, false otherwise. + */ + private function install_atlantis_plugin( OutputInterface $output ): bool { + $plugin_url = 'https://github.com/a8cteam51/a8csp-atlantis/releases/download/v1.0.3/a8csp-atlantis.zip'; + $site_identifier = 'atomic' === $this->host ? $this->site->ID : $this->site->id; + + $this->write_output( $output, '' ); + $this->write_output( $output, 'Installing Atlantis plugin from GitHub...' ); + + try { + // Install the plugin. + $install_command = "plugin install {$plugin_url} --force"; + $this->write_output( $output, " Running: wp {$install_command}" ); + + if ( 'pressable' === $this->host ) { + $install_result = run_pressable_site_wp_cli_command( $site_identifier, $install_command, $this->quiet ); + } else { + $install_result = run_wpcom_site_wp_cli_command( $site_identifier, $install_command, $this->quiet ); + } + + // Check if installation was successful (check both return code and WP-CLI output). + if ( Command::SUCCESS !== $install_result || $this->wp_cli_output_has_errors() ) { + $this->write_output( $output, ' Plugin installation failed (site has a critical error).' ); + return false; + } + + $this->write_output( $output, ' ✓ Plugin installed successfully' ); + + // Activate the plugin. + $activate_command = 'plugin activate a8csp-atlantis'; + $this->write_output( $output, " Running: wp {$activate_command}" ); + + if ( 'pressable' === $this->host ) { + $activate_result = run_pressable_site_wp_cli_command( $site_identifier, $activate_command, $this->quiet ); + } else { + $activate_result = run_wpcom_site_wp_cli_command( $site_identifier, $activate_command, $this->quiet ); + } + + // Check if activation was successful (check both return code and WP-CLI output). + if ( Command::SUCCESS !== $activate_result || $this->wp_cli_output_has_errors() ) { + $this->write_output( $output, ' Plugin activation failed (site has a critical error).' ); + return false; + } + + $this->write_output( $output, ' ✓ Plugin activated successfully' ); + return true; + + } catch ( \Exception $e ) { + $this->write_output( $output, " Failed to install/activate Atlantis plugin: {$e->getMessage()}" ); + return false; + } + } + + /** + * Checks if plugin-autoupdate-filter (or variants) is installed but deactivated, + * and if so, disables the Atlantis autoupdates module via option. + * + * The plugin slug can have variants like: + * - plugin-autoupdate-filter + * - plugin-autoupdate-filter-5 + * - plugin-autoupdate-filter-trunk + * - plugin-autoupdate-filter-1.4.3 + * + * @param OutputInterface $output The output object. + * + * @return void + */ + private function check_autoupdate_filter_status( OutputInterface $output ): void { + $site_identifier = 'atomic' === $this->host ? $this->site->ID : $this->site->id; + + $this->write_output( $output, '' ); + $this->write_output( $output, 'Checking plugin-autoupdate-filter status...' ); + + try { + // Get list of all plugins in JSON format. + $list_command = 'plugin list --format=json'; + + if ( 'pressable' === $this->host ) { + run_pressable_site_wp_cli_command( $site_identifier, $list_command, true ); + } else { + run_wpcom_site_wp_cli_command( $site_identifier, $list_command, true ); + } + + // Check for critical errors in WP-CLI output before parsing. + if ( $this->wp_cli_output_has_errors() ) { + $this->write_output( $output, ' Could not parse plugin list (site has a critical error).' ); + return; + } + + // Get the output from the global variable. + $wp_cli_output = $GLOBALS['wp_cli_output'] ?? ''; + + // Extract JSON array from output (may contain warnings before JSON). + $plugins = null; + if ( preg_match( '/\[.*\]/s', $wp_cli_output, $matches ) ) { + $plugins = json_decode( $matches[0], true ); + } + + if ( ! is_array( $plugins ) ) { + $this->write_output( $output, ' Could not parse plugin list.' ); + return; + } + + // Find any plugin-autoupdate-filter variants. + $autoupdate_plugins = array(); + foreach ( $plugins as $plugin ) { + if ( isset( $plugin['name'] ) && str_starts_with( $plugin['name'], 'plugin-autoupdate-filter' ) ) { + $autoupdate_plugins[] = $plugin; + } + } + + if ( empty( $autoupdate_plugins ) ) { + $this->write_output( $output, ' No plugin-autoupdate-filter variants found on the site.' ); + return; + } + + // Check if any of them are active. + // Note: must-use plugins are always active (they load automatically). + $active_statuses = array( 'active', 'active-network', 'must-use' ); + $any_active = false; + foreach ( $autoupdate_plugins as $plugin ) { + $this->write_output( $output, " Found: {$plugin['name']} (status: {$plugin['status']})" ); + if ( in_array( $plugin['status'], $active_statuses, true ) ) { + $any_active = true; + } + } + + // If found but none are active, disable the Atlantis autoupdates module. + if ( ! $any_active ) { + $this->write_output( $output, ' Plugin found but not active. Disabling Atlantis autoupdates module...' ); + + // Use wp eval to set the option as an array directly (avoids shell escaping issues). + $option_command = 'eval "update_option( \'a8csp_module_autoupdates\', array( \'enabled\' => \'0\' ) );"'; + + if ( 'pressable' === $this->host ) { + run_pressable_site_wp_cli_command( $site_identifier, $option_command, $this->quiet ); + } else { + run_wpcom_site_wp_cli_command( $site_identifier, $option_command, $this->quiet ); + } + + $this->write_output( $output, ' ✓ Atlantis autoupdates module disabled' ); + } else { + $this->write_output( $output, ' Plugin is active, Atlantis autoupdates module will remain enabled.' ); + } + } catch ( \Exception $e ) { + $this->write_output( $output, " Failed to check plugin status: {$e->getMessage()}" ); + $this->write_output( $output, ' Continuing with legacy module removal...' ); + } + } + + /** + * Uninstalls all legacy plugins from WordPress in a single command. + * + * @param OutputInterface $output The output object. + * + * @return void + */ + private function uninstall_plugins_from_wordpress( OutputInterface $output ): void { + $site_identifier = 'atomic' === $this->host ? $this->site->ID : $this->site->id; + $plugins_list = implode( ' ', $this->legacy_modules ); + + $this->write_output( $output, '' ); + $this->write_output( $output, 'Deactivating and uninstalling plugins from WordPress...' ); + + try { + // Run single command to deactivate and uninstall all plugins. + // The --uninstall flag will also deactivate if needed. + $command = "plugin uninstall {$plugins_list} --deactivate"; + + // Skip output in silent mode. + if ( 'pressable' === $this->host ) { + run_pressable_site_wp_cli_command( $site_identifier, $command, $this->quiet ); + } else { + run_wpcom_site_wp_cli_command( $site_identifier, $command, $this->quiet ); + } + + // Check for critical errors in WP-CLI output. + if ( $this->wp_cli_output_has_errors() ) { + $this->write_output( $output, ' Plugin uninstall may have failed (site has a critical error).' ); + return; + } + + $this->write_output( $output, ' ✓ Plugins deactivated and uninstalled from WordPress' ); + } catch ( \Exception $e ) { + $this->write_output( $output, " Failed to uninstall plugins from WordPress: {$e->getMessage()}" ); + $this->write_output( $output, ' This is usually fine if the plugins were not installed on the site.' ); + } + } + + /** + * Check the PHP version of the current site. + * + * @param OutputInterface $output The output object. + * + * @return string|null The PHP version string, or null on error. + */ + private function check_php_version( OutputInterface $output ): ?string { + $this->write_output( $output, '' ); + $this->write_output( $output, 'Checking PHP version...' ); + + // For Pressable sites, try to get PHP version from the API response first. + if ( 'pressable' === $this->host && isset( $this->site->phpVersion ) ) { + $this->php_version = $this->site->phpVersion; + $this->write_output( $output, " PHP version: {$this->php_version} (from API)" ); + return $this->php_version; + } + + // Fall back to WP-CLI for Atomic sites or if API doesn't have PHP version. + $site_identifier = 'atomic' === $this->host ? $this->site->ID : $this->site->id; + + try { + $command = "eval 'echo phpversion();'"; + + if ( 'pressable' === $this->host ) { + run_pressable_site_wp_cli_command( $site_identifier, $command, true ); + } else { + run_wpcom_site_wp_cli_command( $site_identifier, $command, true ); + } + + // Get the output from the global variable. + $wp_cli_output = $GLOBALS['wp_cli_output'] ?? ''; + + // Parse the version from output (should be something like "8.3.0" or "8.2.30"). + if ( preg_match( '/(\d+\.\d+(\.\d+)?)/', $wp_cli_output, $matches ) ) { + $this->php_version = $matches[1]; + $this->write_output( $output, " PHP version: {$this->php_version}" ); + return $this->php_version; + } + + $this->write_output( $output, ' Could not determine PHP version from output.' ); + return null; + } catch ( \Exception $e ) { + $this->write_output( $output, " Failed to check PHP version: {$e->getMessage()}" ); + return null; + } + } + + /** + * Check if the site meets the minimum PHP version requirement. + * + * @return bool True if PHP version is sufficient, false otherwise. + */ + private function meets_php_requirement(): bool { + if ( null === $this->php_version ) { + return false; + } + + return version_compare( $this->php_version, $this->min_php_version, '>=' ); + } + + /** + * Read CSV file and return array of site data. + * + * @param string $csv_path Path to CSV file. + * + * @return array + */ + private function read_csv( string $csv_path ): array { + $csv_data = array(); + $handle = fopen( $csv_path, 'r' ); + + if ( false === $handle ) { + return $csv_data; + } + + // Read header row. + $headers = fgetcsv( $handle ); + if ( false === $headers ) { + fclose( $handle ); + return $csv_data; + } + + // Read data rows. + $row_index = 1; // Start at 1 (0 is header). + while ( ( $row = fgetcsv( $handle ) ) !== false ) { + ++$row_index; + + // Skip empty rows. + if ( empty( array_filter( $row ) ) ) { + continue; + } + + // Ensure row has same number of columns as headers. + if ( count( $row ) !== count( $headers ) ) { + // Pad or truncate row to match header count. + $row = array_pad( array_slice( $row, 0, count( $headers ) ), count( $headers ), '' ); + } + + $site_data = array_combine( $headers, $row ); + if ( $site_data ) { + $csv_data[ $row_index ] = $site_data; + } + } + + fclose( $handle ); + return $csv_data; + } + + /** + * Update a specific row in the CSV file. + * + * @param int $row_index The row index to update (1-based, accounting for header). + * @param string $pr_url The PR URL to set. + * @param string $merged The Merged status ('Y' or ''). + * @param string $note Optional note to add. + * + * @return void + */ + private function update_csv_row( int $row_index, string $pr_url, string $merged, string $note = '' ): void { + if ( ! $this->sites_csv_path || ! file_exists( $this->sites_csv_path ) ) { + return; + } + + // Read all rows. + $rows = array(); + $handle = fopen( $this->sites_csv_path, 'r' ); + + if ( false === $handle ) { + return; + } + + // Read header to find column indices. + $headers = fgetcsv( $handle ); + if ( false === $headers ) { + fclose( $handle ); + return; + } + + // Find column indices (use strict comparison since array_search can return 0). + $pr_index = array_search( 'PR', $headers, true ); + $merged_index = array_search( 'Merged', $headers, true ); + $notes_index = array_search( 'Notes', $headers, true ); + + // If Notes column doesn't exist, we'll add it. + $needs_notes_column = false === $notes_index; + if ( $needs_notes_column ) { + $headers[] = 'Notes'; + $notes_index = count( $headers ) - 1; + } + + $rows[] = $headers; // Add header row. + + $current_row = 1; // Start at 1 (header is row 0). + while ( ( $row = fgetcsv( $handle ) ) !== false ) { + ++$current_row; + + // Update the target row. + if ( $current_row === $row_index ) { + // Ensure row has enough columns. + while ( count( $row ) < count( $headers ) ) { + $row[] = ''; + } + + // Update PR column. + if ( false !== $pr_index ) { + $row[ $pr_index ] = $pr_url; + } + + // Update Merged column. + if ( false !== $merged_index ) { + $row[ $merged_index ] = $merged; + } + + // Update or add Notes column. + if ( $needs_notes_column ) { + // Add Notes column to existing rows that don't have it. + while ( count( $row ) <= $notes_index ) { + $row[] = ''; + } + } + $row[ $notes_index ] = $note; + } elseif ( $needs_notes_column ) { + // Add empty Notes column to other rows. + while ( count( $row ) < count( $headers ) ) { + $row[] = ''; + } + } + + $rows[] = $row; + } + + fclose( $handle ); + + // Write back to file. + $handle = fopen( $this->sites_csv_path, 'w' ); + if ( false === $handle ) { + return; + } + + foreach ( $rows as $row ) { + fputcsv( $handle, $row ); + } + + fclose( $handle ); + } + + /** + * Checks whether the last WP-CLI command output contains fatal/critical error indicators. + * + * The underlying WP-CLI command runners always return Command::SUCCESS as long as + * the SSH connection succeeds, even when WP-CLI itself encounters a fatal PHP error. + * This method inspects the captured output to detect such failures. + * + * @return bool True if the output contains error indicators, false otherwise. + */ + private function wp_cli_output_has_errors(): bool { + $wp_cli_output = $GLOBALS['wp_cli_output'] ?? ''; + + if ( empty( $wp_cli_output ) ) { + return false; + } + + // Check for common WP-CLI / PHP fatal error patterns. + return str_contains( $wp_cli_output, 'Fatal error:' ) + || str_contains( $wp_cli_output, 'critical error on this website' ); + } + + /** + * Reset site state between processing multiple sites. + * + * @return void + */ + private function reset_site_state(): void { + $this->site = null; + $this->deployhq_project = null; + $this->gh_repository = null; + $this->gh_repo_branch = null; + $this->repo_dir = null; + $this->removed_modules = array(); + $this->git_base_branch = 'develop'; + $this->php_version = null; + $this->skip_note = null; + $this->csv_url = null; + $this->skip_repository = false; + $this->deployment_not_found = false; + } + + // endregion +}