diff --git a/composer.json b/composer.json index c44f056de40637bc51b50880fd2b4ff06e8cf533..816fe8e97266df413f7c3c7ec3c4cd0ce252bd10 100644 --- a/composer.json +++ b/composer.json @@ -31,11 +31,18 @@ "test": [ "Composer\\Config::disableProcessTimeout", "scripts/phpunit.sh" - ] + ], + "core-convert": "Drupal\\automatic_updates\\CoreCovert\\Converter::doConvert" }, "scripts-descriptions": { "phpcbf": "Automatically fixes standards violations where possible.", "phpcs": "Checks code for standards compliance.", - "test": "Runs PHPUnit tests." - } + "test": "Runs PHPUnit tests.", + "core-convert": "Converts this module to a core merge request. Excepts 2 arguments. 1) The core clone directory. 2) The core merge request branch." + }, + "autoload": { + "psr-4": { + "Drupal\\automatic_updates\\CoreCovert\\": "scripts/src" + } + } } diff --git a/dictionary.txt b/dictionary.txt index d4d853b8e24ce21cbe3d40bbdc96b11c25944485..7df2fd2279e9a173da6505e4d9471550ea42f665 100644 --- a/dictionary.txt +++ b/dictionary.txt @@ -4,3 +4,4 @@ stager's syncer syncers kirk +unwritable diff --git a/package_manager/tests/src/Build/TemplateProjectTestBase.php b/package_manager/tests/src/Build/TemplateProjectTestBase.php index 836ef51b8be2d669dda29733d705b503d8802cde..0a06608bbbeaeecec76c2f970873311a744a5f2a 100644 --- a/package_manager/tests/src/Build/TemplateProjectTestBase.php +++ b/package_manager/tests/src/Build/TemplateProjectTestBase.php @@ -251,7 +251,6 @@ END; // Now that we know the project was created successfully, we can set the // web root with confidence. $this->webRoot = 'project/' . $this->runComposer('composer config extra.drupal-scaffold.locations.web-root', 'project'); - // BEGIN: DELETE FROM CORE MERGE REQUEST // Install Automatic Updates into the test project and ensure it wasn't // symlinked. @@ -441,7 +440,7 @@ END; return $temp_directory; } - // BEGIN: DELETE FROM CORE MERGE REQUEST + // BEGIN: DELETE FROM CORE MERGE REQUEST. /** * {@inheritdoc} @@ -463,5 +462,5 @@ END; file_put_contents($file, json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); } - // END: DELETE FROM CORE MERGE REQUEST + // END: DELETE FROM CORE MERGE REQUEST. } diff --git a/scripts/src/Converter.php b/scripts/src/Converter.php new file mode 100644 index 0000000000000000000000000000000000000000..0235c894fa710a783aa2edc044ad27bb8b501ec7 --- /dev/null +++ b/scripts/src/Converter.php @@ -0,0 +1,384 @@ +<?php + +namespace Drupal\automatic_updates\CoreCovert; + +use Composer\Script\Event; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Converts the contrib module to core merge request. + * + * File usage: + * + * @code + * composer core-covert /path/to/core merge-request-branch + * @endcode + * + * The core clone should already have the core merge request locally. + */ +class Converter { + + /** + * Prints message. + * + * @param string $msg + * The message to print. + */ + private static function info(string $msg): void { + print "\n$msg"; + } + + /** + * Converts the contrib module to core merge request. + * + * @param \Composer\Script\Event $event + * The Composer event. + */ + public static function doConvert(Event $event): void { + $args = $event->getArguments(); + if (count($args) !== 2) { + throw new \Exception("This scripts 2 arguments, a directory that is a core clone and the branch."); + } + $core_dir = $args[0]; + $core_branch = $args[1]; + if (!is_dir($core_dir)) { + throw new \Exception("$core_dir is not a directory."); + } + $old_machine_name = 'automatic_updates'; + $new_machine_name = 'auto_updates'; + + static::switchToBranches($core_dir, $core_branch); + self::info('Switched branches'); + $fs = new Filesystem(); + + $core_module_path = static::getCoreModulePath($core_dir); + $package_manager_core_path = $core_dir . "/core/modules/package_manager"; + // Remove old module. + $fs->remove($core_module_path); + self::info('Removed old core module'); + $fs->remove($package_manager_core_path); + self::info("Removed package manager"); + + $fs->mirror(self::getContribDir(), $core_module_path); + self::info('Mirrored into core module'); + + // Remove unneeded. + $removals = [ + 'package_manager/package_manager.install', + 'automatic_updates_9_3_shim', + 'automatic_updates_extensions', + 'drupalci.yml', + 'README.md', + '.git', + 'pcre.ini', + 'composer.json', + '.gitattributes', + '.gitignore', + 'DEVELOPING.md', + 'scripts', + 'dictionary.txt', + ]; + $removals = array_map(function ($path) use ($core_module_path) { + return "$core_module_path/$path"; + }, $removals); + $fs->remove($removals); + self::info('Remove not needed'); + + // Replace in file names and contents. + $replacements = [ + $old_machine_name => $new_machine_name, + 'AutomaticUpdates' => 'AutoUpdates', + "core_version_requirement: ^9.3" => "package: Core\nversion: VERSION\nlifecycle: experimental", + ]; + foreach ($replacements as $search => $replace) { + static::renameFiles($core_module_path, $search, $replace); + static::replaceContents($core_module_path, $search, $replace); + } + self::info('Replacements done.'); + + static::removeLines($core_dir); + self::info('Remove unneeded lines'); + $fs->rename("$core_module_path/package_manager", $core_dir . "/core/modules/package_manager"); + self::info('Move package manager'); + + static::addWordsToDictionary($core_dir, self::getContribDir() . "/dictionary.txt"); + self::info("Added to dictionary"); + static::runCoreChecks($core_dir); + self::info('Ran core checks'); + static::makeCommit($core_dir); + self::info('Make commit'); + self::info("Done. Probably good but you should check before you push."); + } + + /** + * Returns the path to the root of the contrib module. + * + * @return string + * The full path to the root of the contrib module. + */ + private static function getContribDir(): string { + return realpath(__DIR__ . '/../..'); + } + + /** + * Returns the path where the contrib module will be placed in Drupal Core. + * + * @param string $core_dir + * The path to the root of Drupal Core. + * + * @return string + * The path where the contrib module will be placed in Drupal Core + */ + private static function getCoreModulePath(string $core_dir): string { + return $core_dir . '/core/modules/auto_updates'; + } + + /** + * Replaces a string in the contents of the module files. + * + * @param string $core_module_path + * The path of module in Drupal Core. + * @param string $search + * The string to be replaced. + * @param string $replace + * The string to replace. + */ + private static function replaceContents(string $core_module_path, string $search, string $replace): void { + $files = static::getDirContents($core_module_path, TRUE); + foreach ($files as $file) { + $filePath = $file->getRealPath(); + file_put_contents($filePath, str_replace($search, $replace, file_get_contents($filePath))); + } + } + + /** + * Renames the module files. + * + * @param string $core_module_path + * The path of module in Drupal Core. + * @param string $old_pattern + * The old file name. + * @param string $new_pattern + * The new file name. + */ + private static function renameFiles(string $core_module_path, string $old_pattern, string $new_pattern): void { + + $files = static::getDirContents($core_module_path); + + // Keep a record of the files and directories to change. + // We will change all the files first, so we don't change the location of + // any files in the middle. This probably won't work if we had nested + // folders with the pattern on 2 folder levels, but we don't. + $filesToChange = []; + $dirsToChange = []; + foreach ($files as $file) { + $fileName = $file->getFilename(); + if ($fileName === '.') { + $fullPath = $file->getPath(); + $parts = explode('/', $fullPath); + $name = array_pop($parts); + $path = "/" . implode('/', $parts); + } + else { + $name = $fileName; + $path = $file->getPath(); + } + if (strpos($name, $old_pattern) !== FALSE) { + $new_filename = str_replace($old_pattern, $new_pattern, $name); + if ($file->isFile()) { + $filesToChange[$file->getRealPath()] = $file->getPath() . "/$new_filename"; + } + else { + // Store directories by path depth. + $depth = count(explode('/', $path)); + $dirsToChange[$depth][$file->getRealPath()] = "$path/$new_filename"; + } + } + } + foreach ($filesToChange as $old => $new) { + (new Filesystem())->rename($old, $new); + } + // Rename directories starting with the most nested to avoid renaming + // parents directories first. + krsort($dirsToChange); + foreach ($dirsToChange as $dirs) { + foreach ($dirs as $old => $new) { + (new Filesystem())->rename($old, $new); + } + } + } + + /** + * Gets the contents of a directory. + * + * @param string $path + * The path of the directory. + * @param bool $excludeDirs + * (optional) If TRUE, all directories will be excluded. Defaults to FALSE. + * + * @return \SplFileInfo[] + * Array of objects containing file information. + */ + private static function getDirContents(string $path, bool $excludeDirs = FALSE): array { + $rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path)); + + $files = []; + /** @var \SplFileInfo $file */ + foreach ($rii as $file) { + if ($excludeDirs && $file->isDir()) { + continue; + } + $files[] = $file; + } + + return $files; + } + + /** + * Ensures the git status is clean. + * + * @return bool + * TRUE if git status is clean , otherwise returns a exception. + */ + private static function ensureGitClean(): bool { + $status_output = shell_exec('git status'); + if (strpos($status_output, 'nothing to commit, working tree clean') === FALSE) { + throw new \Exception("git not clean: " . $status_output); + } + return TRUE; + } + + /** + * Gets the current git branch. + * + * @return string + * The current git branch. + */ + private static function getCurrentBranch(): string { + return trim(shell_exec('git rev-parse --abbrev-ref HEAD')); + } + + /** + * Switches to the branches we need. + * + * @param string $core_dir + * The path to the root of Drupal Core. + * @param string $core_branch + * The core merge request branch. + */ + private static function switchToBranches(string $core_dir, string $core_branch): void { + static::switchToBranch('8.x-2.x'); + chdir($core_dir); + static::switchToBranch($core_branch); + } + + /** + * Switches to a branches and makes sure it is clean. + * + * @param string $branch + * The branch to switch to. + */ + private static function switchToBranch(string $branch): void { + static::ensureGitClean(); + shell_exec("git checkout $branch"); + if ($branch !== static::getCurrentBranch()) { + throw new \Exception("could not check $branch"); + } + } + + /** + * Makes commit in the root of Drupal Core. + * + * @param string $core_dir + * The path to the root of Drupal Core. + */ + private static function makeCommit(string $core_dir): void { + chdir(self::getContribDir()); + self::ensureGitClean(); + $hash = trim(shell_exec('git rev-parse HEAD')); + $message = trim(shell_exec("git show -s --format='%s'")); + chdir($core_dir); + shell_exec('git add core'); + shell_exec("git commit -m 'Contrib: $message - https://git.drupalcode.org/project/automatic_updates/-/commit/$hash'"); + } + + /** + * Adds new words to cspell dictionary. + * + * @param string $core_dir + * The path to the root of Drupal Core. + * @param string $dict_file_to_merge + * The path to the dictionary file with additional words. + */ + private static function addWordsToDictionary(string $core_dir, string $dict_file_to_merge): void { + if (!file_exists($dict_file_to_merge)) { + throw new \LogicException(sprintf('%s does not exist', $dict_file_to_merge)); + } + $dict_file = $core_dir . '/core/misc/cspell/dictionary.txt'; + $contents = file_get_contents($dict_file); + $words = explode("\n", $contents); + $words = array_filter($words); + $new_words = explode("\n", file_get_contents($dict_file_to_merge)); + $words = array_merge($words, $new_words); + $words = array_unique($words); + asort($words); + file_put_contents($dict_file, implode("\n", $words)); + } + + /** + * Runs code quality checks. + * + * @param string $core_dir + * The path to the root of Drupal Core. + */ + private static function runCoreChecks(string $core_dir): void { + chdir($core_dir); + $result = NULL; + system(' sh ./core/scripts/dev/commit-code-check.sh --branch 9.5.x', $result); + if ($result !== 0) { + print "ðŸ˜commit-code-check.sh failed"; + exit(1); + } + print "🎉 commit-code-check.sh passed!"; + } + + /** + * Removes lines from the module based on a starting and ending token + * These are lines that are not need in core at all. + * + * @param string $core_dir + * The path to the root of Drupal Core. + */ + private static function removeLines(string $core_dir): void { + $files = static::getDirContents(static::getCoreModulePath($core_dir), TRUE); + foreach ($files as $file) { + $filePath = $file->getRealPath(); + $contents = file_get_contents($filePath); + $lines = explode("\n", $contents); + $skip = FALSE; + $newLines = []; + foreach ($lines as $line) { + if (strpos($line, '// BEGIN: DELETE FROM CORE MERGE REQUEST')) { + if ($skip) { + throw new \Exception("Already found begin"); + } + $skip = TRUE; + } + if (!$skip) { + $newLines[] = $line; + } + if (strpos($line, '// END: DELETE FROM CORE MERGE REQUEST')) { + if (!$skip) { + throw new \Exception("Didn't find matching begin"); + } + $skip = FALSE; + } + } + if ($skip) { + throw new \Exception("Didn't find ending token"); + } + file_put_contents($filePath, implode("\n", $newLines)); + } + } + +}