├── LICENSE ├── bin ├── moodle-plugin-ci └── validate-version ├── composer.json ├── composer.lock ├── res ├── config │ └── phpmd.xml └── template │ └── config.php.txt └── src ├── Bridge ├── MessDetectorRenderer.php ├── Moodle.php ├── MoodleConfig.php ├── MoodlePlugin.php ├── MoodlePluginCollection.php └── Vendors.php ├── Command ├── AbstractMoodleCommand.php ├── AbstractPluginCommand.php ├── AddConfigCommand.php ├── AddPluginCommand.php ├── BehatCommand.php ├── CodeCheckerCommand.php ├── CodeFixerCommand.php ├── CopyPasteDetectorCommand.php ├── CoverallsUploadCommand.php ├── ExecuteTrait.php ├── GruntCommand.php ├── InstallCommand.php ├── MessDetectorCommand.php ├── MoodleOptionTrait.php ├── MustacheCommand.php ├── PHPDocCommand.php ├── PHPLintCommand.php ├── PHPUnitCommand.php ├── ParallelCommand.php ├── SavePointsCommand.php ├── SelfUpdateCommand.php └── ValidateCommand.php ├── Installer ├── AbstractInstaller.php ├── ConfigDumper.php ├── Database │ ├── AbstractDatabase.php │ ├── DatabaseResolver.php │ ├── MariaDBDatabase.php │ ├── MySQLDatabase.php │ └── PostgresDatabase.php ├── EnvDumper.php ├── Install.php ├── InstallOutput.php ├── InstallerCollection.php ├── InstallerFactory.php ├── MoodleAppInstaller.php ├── MoodleInstaller.php ├── PluginInstaller.php ├── TestSuiteInstaller.php └── VendorInstaller.php ├── Model └── GruntTaskModel.php ├── Parser ├── CodeParser.php └── StatementFilter.php ├── PluginValidate ├── Finder │ ├── AbstractParserFinder.php │ ├── BehatTagFinder.php │ ├── CapabilityFinder.php │ ├── ClassFinder.php │ ├── FileTokens.php │ ├── FinderInterface.php │ ├── FunctionCallFinder.php │ ├── FunctionFinder.php │ ├── LangFinder.php │ ├── TableFinder.php │ ├── TablePrefixFinder.php │ └── Token.php ├── Plugin.php ├── PluginValidate.php └── Requirements │ ├── AbstractRequirements.php │ ├── AuthRequirements.php │ ├── BlockRequirements.php │ ├── DataformatRequirements.php │ ├── FilterRequirements.php │ ├── FormatRequirements.php │ ├── GenericRequirements.php │ ├── ModuleRequirements.php │ ├── QuestionRequirements.php │ ├── RepositoryRequirements.php │ ├── RequirementsResolver.php │ └── ThemeRequirements.php ├── Process ├── Execute.php ├── MoodleDebugException.php ├── MoodlePhpException.php └── MoodleProcess.php ├── StandardResolver.php └── Validate.php /bin/moodle-plugin-ci: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | usePutenv(true); 72 | $env->load(ENV_FILE); 73 | } 74 | 75 | $version = (new SebastianBergmann\Version(MOODLE_PLUGIN_CI_VERSION, dirname(__DIR__)))->getVersion(); 76 | // Let's make Box to find the better version for the phar from git. 77 | if (MOODLE_PLUGIN_CI_BOXED === 'BOXED') { 78 | $version = '@package_version@'; 79 | } 80 | 81 | $application = new Application('Moodle Plugin CI', $version); 82 | $application->add(new AddConfigCommand()); 83 | $application->add(new AddPluginCommand(ENV_FILE)); 84 | $application->add(new BehatCommand()); 85 | $application->add(new CodeCheckerCommand()); 86 | $application->add(new CodeFixerCommand()); 87 | $application->add(new CopyPasteDetectorCommand()); 88 | $application->add(new CoverallsUploadCommand()); 89 | $application->add(new GruntCommand()); 90 | $application->add(new InstallCommand(ENV_FILE)); 91 | $application->add(new MessDetectorCommand()); 92 | $application->add(new MustacheCommand()); 93 | $application->add(new ParallelCommand()); 94 | $application->add(new PHPDocCommand()); 95 | $application->add(new PHPLintCommand()); 96 | $application->add(new PHPUnitCommand()); 97 | $application->add(new SavePointsCommand()); 98 | $application->add(new ValidateCommand()); 99 | 100 | // Only add the self update command if we are boxed as a phar. 101 | if (MOODLE_PLUGIN_CI_BOXED === 'BOXED') { 102 | $application->add(new SelfUpdateCommand()); 103 | } 104 | $application->run(); 105 | -------------------------------------------------------------------------------- /bin/validate-version: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | binary, error. 50 | if (version_compare($changelogVersion, $binaryVersion, '>')) { 51 | fwrite(STDERR, 'Version in docs/CHANGELOG.md (' . 52 | $changelogVersion . ') is newer than version in bin/moodle-plugin-ci (' . 53 | $binaryVersion . '). Please, check!' . PHP_EOL); 54 | exit(1); 55 | } 56 | 57 | // Version in change log < binary, error. 58 | if (version_compare($changelogVersion, $binaryVersion, '<')) { 59 | fwrite(STDERR, 'Version in docs/CHANGELOG.md (' . 60 | $changelogVersion . ') is older than version in bin/moodle-plugin-ci (' . 61 | $binaryVersion . '). Please, check!' . PHP_EOL); 62 | exit(1); 63 | } 64 | 65 | // Arrived here, versions match, all ok. 66 | fwrite(STDOUT, 'Matching version found: ' . $changelogVersion . PHP_EOL); 67 | exit(0); 68 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moodlehq/moodle-plugin-ci", 3 | "description": "Helps running Moodle plugins analysis checks and tests under various CI environments.", 4 | "keywords": ["moodle", "travis", "ci", "testing", "github", "actions"], 5 | "type": "project", 6 | "license": "GPL-3.0-or-later", 7 | "authors": [ 8 | { 9 | "name": "Eloy Lafuente", 10 | "email": "stronk7@moodle.com", 11 | "homepage": "https://moodle.com", 12 | "role": "Maintainer" 13 | }, 14 | { 15 | "name": "Ruslan Kabalin", 16 | "email": "ruslan@moodle.com", 17 | "homepage": "https://moodle.com", 18 | "role": "Maintainer" 19 | }, 20 | { 21 | "name": "Mark Nielsen", 22 | "email": "mark.nielsen@blackboard.com", 23 | "homepage": "https://www.blackboard.com", 24 | "role": "Developer" 25 | }, 26 | { 27 | "name": "Sam Chaffee", 28 | "email": "sam.chaffee@blackboard.com", 29 | "homepage": "https://www.blackboard.com", 30 | "role": "Developer" 31 | } 32 | ], 33 | "support": { 34 | "issues": "https://github.com/moodlehq/moodle-plugin-ci/issues", 35 | "source": "https://github.com/moodlehq/moodle-plugin-ci", 36 | "docs": "https://moodlehq.github.io/moodle-plugin-ci/" 37 | }, 38 | "repositories": [ 39 | { 40 | "type": "vcs", 41 | "url": "https://github.com/moodlehq/moodle-local_ci.git" 42 | }, 43 | { 44 | "type": "package", 45 | "package": { 46 | "name": "moodlehq/moodle-local_moodlecheck", 47 | "version": "1.3.2", 48 | "source": { 49 | "url": "https://github.com/moodlehq/moodle-local_moodlecheck.git", 50 | "type": "git", 51 | "reference": "v1.3.2" 52 | } 53 | } 54 | } 55 | ], 56 | "require": { 57 | "php": ">=7.4", 58 | "moodlehq/moodle-cs": "^3.4.10", 59 | "moodlehq/moodle-local_ci": "^1.0.31", 60 | "moodlehq/moodle-local_moodlecheck": "^1.3.2", 61 | "sebastian/phpcpd": "^6.0.3", 62 | "sebastian/version": "^3.0.2", 63 | "phpunit/php-timer": "^5.0.3", 64 | "phpmd/phpmd": "^2.14.0", 65 | "symfony/dotenv": "^5.4", 66 | "symfony/filesystem": "^5.4", 67 | "symfony/finder": "^5.4", 68 | "symfony/console": "^5.4", 69 | "symfony/yaml": "^5.4", 70 | "symfony/process": "^5.4", 71 | "php-parallel-lint/php-parallel-lint": "^1.3.2", 72 | "php-parallel-lint/php-console-highlighter": "^1.0.0", 73 | "psr/log": "^1.1.4", 74 | "nikic/php-parser": "^4.14", 75 | "marcj/topsort": "^2.0.0", 76 | "phpcompatibility/php-compatibility": "dev-develop#96072c30", 77 | "laravel-zero/phar-updater": "^1.0.0" 78 | }, 79 | "require-dev": { 80 | "phpunit/phpunit": "^9.6", 81 | "mockery/mockery": "^1.5.0", 82 | "friendsofphp/php-cs-fixer": "^3.59.3", 83 | "vimeo/psalm": "5.19.*" 84 | }, 85 | "config": { 86 | "platform": { 87 | "php": "7.4.0" 88 | }, 89 | "allow-plugins": { 90 | "dealerdirect/phpcodesniffer-composer-installer": true 91 | } 92 | }, 93 | "autoload": { 94 | "psr-4": { 95 | "MoodlePluginCI\\": "src/" 96 | } 97 | }, 98 | "autoload-dev": { 99 | "psr-4": { 100 | "MoodlePluginCI\\Tests\\": "tests/" 101 | } 102 | }, 103 | "bin": [ 104 | "bin/moodle-plugin-ci" 105 | ], 106 | "scripts": { 107 | "post-install-cmd": "@local-ci-install", 108 | "post-update-cmd": "@local-ci-install", 109 | "post-create-project-cmd": "@local-ci-install", 110 | "local-ci-install": [ 111 | "cd vendor/moodlehq/moodle-local_ci && npm install --no-progress" 112 | ] 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /res/config/phpmd.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | The Moodle rule set contains a collection of rules that finds software design related problems. 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /res/template/config.php.txt: -------------------------------------------------------------------------------- 1 | dbtype = '{{DBTYPE}}'; 8 | $CFG->dblibrary = '{{DBLIBRARY}}'; 9 | $CFG->dbhost = '{{DBHOST}}'; 10 | $CFG->dbname = '{{DBNAME}}'; 11 | $CFG->dbuser = '{{DBUSER}}'; 12 | $CFG->dbpass = '{{DBPASS}}'; 13 | $CFG->prefix = 'mdl_'; 14 | $CFG->dboptions = [ 15 | 'dbport' => '{{DBPORT}}', 16 | ]; 17 | 18 | $CFG->wwwroot = '{{WWWROOT}}'; 19 | $CFG->dataroot = '{{DATAROOT}}'; 20 | $CFG->admin = 'admin'; 21 | 22 | $CFG->directorypermissions = 02777; 23 | 24 | // Show debugging messages. 25 | $CFG->debug = PHP_VERSION_ID >= 80000 ? E_ALL : E_ALL | E_STRICT; 26 | $CFG->debugdisplay = 1; 27 | 28 | // No emails. 29 | $CFG->noemailever = true; 30 | $CFG->noreplyaddress = 'noreply@localhost.local'; 31 | 32 | // App settings. 33 | $CFG->behat_ionic_wwwroot = '{{BEHATIONICWWWROOT}}'; 34 | 35 | // PHPUnit settings. 36 | $CFG->phpunit_prefix = 'phpu_'; 37 | $CFG->phpunit_dataroot = '{{PHPUNITDATAROOT}}'; 38 | 39 | // Behat settings. 40 | $CFG->behat_prefix = 'behat_'; 41 | $CFG->behat_dataroot = '{{BEHATDATAROOT}}'; 42 | $CFG->behat_wwwroot = '{{BEHATWWWROOT}}'; 43 | $CFG->behat_faildump_path = '{{BEHATDUMP}}'; 44 | $CFG->behat_profiles = [ 45 | 'default' => [ 46 | 'browser' => '{{BEHATDEFAULTBROWSER}}', 47 | 'wd_host' => '{{BEHATWDHOST}}', 48 | 'capabilities' => {{BEHATDEFAULTCAPABILITIES}}, 49 | ], 50 | 'chrome' => [ 51 | 'browser' => 'chrome', 52 | 'wd_host' => '{{BEHATWDHOST}}', 53 | 'capabilities' => {{BEHATCHROMECAPABILITIES}}, 54 | ], 55 | 'firefox' => [ 56 | 'browser' => 'firefox', 57 | 'wd_host' => '{{BEHATWDHOST}}', 58 | 'capabilities' => {{BEHATFIREFOXCAPABILITIES}}, 59 | ], 60 | ]; 61 | 62 | {{EXTRACONFIG}} 63 | 64 | require_once(__DIR__.'/lib/setup.php'); 65 | // There is no php closing tag in this file, 66 | // it is intentional because it prevents trailing whitespace problems! 67 | -------------------------------------------------------------------------------- /src/Bridge/MessDetectorRenderer.php: -------------------------------------------------------------------------------- 1 | output = $output; 37 | $this->basePath = $basePath; 38 | } 39 | 40 | public function renderReport(Report $report): void 41 | { 42 | $this->output->writeln(''); 43 | 44 | $groupByFile = []; 45 | foreach ($report->getRuleViolations() as $violation) { 46 | if ($filename = $violation->getFileName()) { 47 | $groupByFile[$filename][] = $violation; 48 | } 49 | } 50 | 51 | foreach ($report->getErrors() as $error) { 52 | $groupByFile[$error->getFile()][] = $error; 53 | } 54 | foreach ($groupByFile as $file => $problems) { 55 | $violationCount = 0; 56 | $errorCount = 0; 57 | 58 | $table = new Table($this->output); 59 | $table->setStyle('borderless'); 60 | foreach ($problems as $problem) { 61 | if ($problem instanceof RuleViolation) { 62 | $table->addRow([$problem->getBeginLine(), 'VIOLATION', $problem->getDescription()]); 63 | ++$violationCount; 64 | } 65 | if ($problem instanceof ProcessingError) { 66 | $table->addRow(['-', 'ERROR', $problem->getMessage()]); 67 | ++$errorCount; 68 | } 69 | } 70 | 71 | $this->output->writeln([ 72 | sprintf('FILE: %s', str_replace($this->basePath . '/', '', (string) $file)), 73 | sprintf('FOUND %d ERRORS AND %d VIOLATIONS', $errorCount, $violationCount), 74 | ]); 75 | $table->render(); 76 | $this->output->writeln(''); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Bridge/Moodle.php: -------------------------------------------------------------------------------- 1 | directory = $directory; 40 | } 41 | 42 | /** 43 | * Load Moodle config so we can use Moodle APIs. 44 | */ 45 | public function requireConfig(): void 46 | { 47 | global $CFG; 48 | 49 | if (!defined('CLI_SCRIPT')) { 50 | define('CLI_SCRIPT', true); 51 | } 52 | if (!defined('IGNORE_COMPONENT_CACHE')) { 53 | define('IGNORE_COMPONENT_CACHE', true); 54 | } 55 | if (!defined('CACHE_DISABLE_ALL')) { 56 | define('CACHE_DISABLE_ALL', true); 57 | } 58 | if (!defined('ABORT_AFTER_CONFIG')) { 59 | // Need this since Moodle will not be fully installed. 60 | define('ABORT_AFTER_CONFIG', true); 61 | } 62 | $path = $this->directory . '/config.php'; 63 | 64 | if (!is_file($path)) { 65 | throw new \RuntimeException('Failed to find Moodle config file'); 66 | } 67 | 68 | /** @noinspection PhpIncludeInspection */ 69 | require_once $path; 70 | 71 | // Save a local reference to Moodle config. 72 | if (empty($this->cfg)) { 73 | $this->cfg = $CFG; 74 | } 75 | } 76 | 77 | /** 78 | * Normalize the component into the type and plugin name. 79 | * 80 | * @param string $component 81 | * 82 | * @return array 83 | */ 84 | public function normalizeComponent(string $component): array 85 | { 86 | $this->requireConfig(); 87 | 88 | /* @noinspection PhpUndefinedClassInspection */ 89 | return \core_component::normalize_component($component); 90 | } 91 | 92 | /** 93 | * Get the absolute install directory path within Moodle. 94 | * 95 | * @param string $component Moodle component, EG: mod_forum 96 | * 97 | * @return string Absolute path, EG: /path/to/mod/forum 98 | */ 99 | public function getComponentInstallDirectory(string $component): string 100 | { 101 | list($type, $name) = $this->normalizeComponent($component); 102 | 103 | // Must use reflection to avoid using static cache. 104 | /* @noinspection PhpUndefinedClassInspection */ 105 | $method = new \ReflectionMethod(\core_component::class, 'fetch_plugintypes'); 106 | $method->setAccessible(true); 107 | $result = $method->invoke(null); 108 | 109 | $plugintypes = $this->getBranch() >= 500 ? $result['plugintypes'] : $result[0]; 110 | 111 | if (!array_key_exists($type, $plugintypes)) { 112 | throw new \InvalidArgumentException(sprintf('The component %s has an unknown plugin type of %s', $component, $type)); 113 | } 114 | 115 | return $plugintypes[$type] . '/' . $name; 116 | } 117 | 118 | /** 119 | * Get the branch number, EG: 29, 30, etc. 120 | * 121 | * @return int 122 | */ 123 | public function getBranch(): int 124 | { 125 | $filter = new StatementFilter(); 126 | $parser = new CodeParser(); 127 | 128 | $statements = $parser->parseFile($this->directory . '/version.php'); 129 | $assign = $filter->findFirstVariableAssignment($statements, 'branch', 'Failed to find $branch in Moodle version.php'); 130 | 131 | if ($assign->expr instanceof String_) { 132 | return (int) $assign->expr->value; 133 | } 134 | 135 | throw new \RuntimeException('Failed to find Moodle branch version'); 136 | } 137 | 138 | /** 139 | * Get a Moodle config value. 140 | * 141 | * @param string $name the config name 142 | * 143 | * @return string 144 | */ 145 | public function getConfig(string $name): string 146 | { 147 | $this->requireConfig(); 148 | 149 | if (null === $this->cfg || !property_exists($this->cfg, $name)) { 150 | throw new \RuntimeException(sprintf('Failed to find $CFG->%s in Moodle config file', $name)); 151 | } 152 | 153 | return $this->cfg->$name; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Bridge/MoodleConfig.php: -------------------------------------------------------------------------------- 1 | ['chromeOptions' => ['args' => $chromeinsecureargs]]]"; 37 | 38 | $template = file_get_contents(__DIR__ . '/../../res/template/config.php.txt'); 39 | $behatdefaultbrowser = getenv('MOODLE_BEHAT_DEFAULT_BROWSER') ?: (getenv('MOODLE_APP') ? 'chrome' : 'firefox'); 40 | $behatchromecapabilities = getenv('MOODLE_BEHAT_CHROME_CAPABILITIES') ?: (getenv('MOODLE_APP') ? $chromeinsecurecapabilities : '[]'); 41 | $behatfirefoxcapabilities = getenv('MOODLE_BEHAT_FIREFOX_CAPABILITIES') ?: '[]'; 42 | $appprotocol = getenv('MOODLE_APP_PROTOCOL') ?: 'https'; 43 | $variables = [ 44 | '{{DBTYPE}}' => $database->type, 45 | '{{DBLIBRARY}}' => $database->library, 46 | '{{DBHOST}}' => $database->host, 47 | '{{DBPORT}}' => $database->port, 48 | '{{DBNAME}}' => $database->name, 49 | '{{DBUSER}}' => $database->user, 50 | '{{DBPASS}}' => $database->pass, 51 | '{{WWWROOT}}' => 'http://localhost/moodle', 52 | '{{DATAROOT}}' => $dataDir, 53 | '{{PHPUNITDATAROOT}}' => $dataDir . '/phpu_moodledata', 54 | '{{BEHATDATAROOT}}' => $dataDir . '/behat_moodledata', 55 | '{{BEHATDUMP}}' => $dataDir . '/behat_dump', 56 | '{{BEHATWWWROOT}}' => getenv('MOODLE_BEHAT_WWWROOT') ?: 'http://localhost:8000', 57 | '{{BEHATWDHOST}}' => getenv('MOODLE_BEHAT_WDHOST') ?: 'http://localhost:4444/wd/hub', 58 | '{{BEHATDEFAULTBROWSER}}' => $behatdefaultbrowser, 59 | '{{BEHATIONICWWWROOT}}' => getenv('MOODLE_APP') ? "$appprotocol://localhost:8100" : (getenv('MOODLE_BEHAT_IONIC_WWWROOT') ?: ''), 60 | '{{BEHATDEFAULTCAPABILITIES}}' => $behatdefaultbrowser === 'chrome' ? $behatchromecapabilities : $behatfirefoxcapabilities, 61 | '{{BEHATCHROMECAPABILITIES}}' => $behatchromecapabilities, 62 | '{{BEHATFIREFOXCAPABILITIES}}' => $behatfirefoxcapabilities, 63 | '{{EXTRACONFIG}}' => self::PLACEHOLDER, 64 | ]; 65 | 66 | return str_replace(array_keys($variables), array_values($variables), $template); 67 | } 68 | 69 | /** 70 | * Adds a line of PHP code into the config file. 71 | * 72 | * @param string $contents The config file contents 73 | * @param string $lineToAdd The line to inject 74 | * 75 | * @return string 76 | */ 77 | public function injectLine(string $contents, string $lineToAdd): string 78 | { 79 | if (strpos($contents, self::PLACEHOLDER) === false) { 80 | throw new \RuntimeException('Failed to find placeholder in config file, file might be malformed'); 81 | } 82 | 83 | return str_replace(self::PLACEHOLDER, $lineToAdd . "\n" . self::PLACEHOLDER, $contents); 84 | } 85 | 86 | /** 87 | * Read a config file. 88 | * 89 | * @param string $file Path to the file to read 90 | * 91 | * @return string 92 | */ 93 | public function read(string $file): string 94 | { 95 | if (!file_exists($file)) { 96 | throw new \InvalidArgumentException('Failed to find Moodle config.php file, perhaps Moodle has not been installed yet'); 97 | } 98 | 99 | // Must suppress as unreadable files emit PHP warning, but we handle it below. 100 | $contents = @file_get_contents($file); 101 | 102 | if ($contents === false) { 103 | throw new \RuntimeException('Failed to read from the Moodle config.php file'); 104 | } 105 | 106 | return $contents; 107 | } 108 | 109 | /** 110 | * Write the config file contents out to the config file. 111 | * 112 | * @param string $file File path 113 | * @param string $contents Config file contents 114 | */ 115 | public function dump(string $file, string $contents): void 116 | { 117 | $filesystem = new Filesystem(); 118 | $filesystem->dumpFile($file, $contents); 119 | $filesystem->chmod($file, 0644); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Bridge/MoodlePluginCollection.php: -------------------------------------------------------------------------------- 1 | items[] = $item; 30 | } 31 | 32 | /** 33 | * @return MoodlePlugin[] 34 | */ 35 | public function all(): array 36 | { 37 | return $this->items; 38 | } 39 | 40 | public function count(): int 41 | { 42 | return count($this->items); 43 | } 44 | 45 | public function sortByDependencies(): self 46 | { 47 | $elements = []; 48 | foreach ($this->items as $item) { 49 | $elements[$item->getComponent()] = []; 50 | } 51 | 52 | $subpluginTypes = []; 53 | foreach ($this->items as $item) { 54 | foreach ($item->getSubpluginTypes() as $type) { 55 | $subpluginTypes[$type] = $item->getComponent(); 56 | } 57 | } 58 | 59 | // Loop through a second time, only adding dependencies that exist in our list. 60 | foreach ($this->items as $item) { 61 | $dependencies = $item->getDependencies(); 62 | foreach ($dependencies as $dependency) { 63 | if (array_key_exists($dependency, $elements)) { 64 | $elements[$item->getComponent()][] = $dependency; 65 | } 66 | } 67 | 68 | // Add implied dependencies for subplugins. 69 | $type = strtok($item->getComponent(), '_'); 70 | if (array_key_exists($type, $subpluginTypes)) { 71 | $elements[$item->getComponent()][] = $subpluginTypes[$type]; 72 | } 73 | } 74 | 75 | $sorter = new StringSort($elements, false); 76 | $results = $sorter->sort(); 77 | 78 | $sorted = new self(); 79 | foreach ($results as $result) { 80 | foreach ($this->items as $item) { 81 | if ($result === $item->getComponent()) { 82 | $sorted->add($item); 83 | break; 84 | } 85 | } 86 | } 87 | 88 | if ($this->count() !== $sorted->count()) { 89 | throw new \LogicException('The sorted list of plugins does not match the size of original list'); 90 | } 91 | 92 | return $sorted; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Bridge/Vendors.php: -------------------------------------------------------------------------------- 1 | path = $path; 43 | $this->xml = simplexml_load_file($this->path); 44 | } 45 | 46 | /** 47 | * Returns all the third party library paths from the XML file. 48 | * 49 | * @return array 50 | */ 51 | public function getVendorPaths() 52 | { 53 | $base = dirname($this->path); 54 | $paths = []; 55 | $locations = $this->xml->xpath('/libraries/library/location') ?? false ?: []; 56 | foreach ($locations as $location) { 57 | $location = trim((string) $location, '/'); 58 | $location = $base . '/' . $location; 59 | 60 | if (strpos($location, '*') !== false) { 61 | $locations = glob($location); 62 | if (empty($locations)) { 63 | throw new \RuntimeException(sprintf('Failed to run glob on path: %s', $location)); 64 | } 65 | $paths = array_merge($paths, $locations); 66 | } elseif (!file_exists($location)) { 67 | throw new \RuntimeException(sprintf('The %s contains a non-existent path: %s', $this->path, $location)); 68 | } else { 69 | $paths[] = $location; 70 | } 71 | } 72 | 73 | return $paths; 74 | } 75 | 76 | /** 77 | * Returns all the third party library paths from the XML file. The paths will be relative to the XML file. 78 | * 79 | * @return array 80 | */ 81 | public function getRelativeVendorPaths() 82 | { 83 | $base = dirname($this->path) . '/'; 84 | 85 | return array_map(function ($path) use ($base) { 86 | return str_replace($base, '', $path); 87 | }, $this->getVendorPaths()); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Command/AbstractMoodleCommand.php: -------------------------------------------------------------------------------- 1 | addMoodleOption($this); 31 | } 32 | 33 | protected function initialize(InputInterface $input, OutputInterface $output): void 34 | { 35 | parent::initialize($input, $output); 36 | $this->initializeMoodle($input); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Command/AbstractPluginCommand.php: -------------------------------------------------------------------------------- 1 | addArgument('plugin', $mode, 'Path to the plugin', $plugin); 37 | } 38 | 39 | protected function initialize(InputInterface $input, OutputInterface $output): void 40 | { 41 | if (!isset($this->plugin) && $input->getArgument('plugin') !== null) { 42 | $validate = new Validate(); 43 | $pluginDir = realpath($validate->directory($input->getArgument('plugin'))); 44 | $this->plugin = new MoodlePlugin($pluginDir); 45 | 46 | // This allows for command specific configs. 47 | $this->plugin->context = $this->getName(); 48 | } 49 | } 50 | 51 | /** 52 | * @param OutputInterface $output 53 | * @param string $message 54 | */ 55 | protected function outputHeading(OutputInterface $output, string $message): void 56 | { 57 | $message = sprintf($message, $this->plugin->getComponent()); 58 | $output->writeln(sprintf(' RUN %s', $message)); 59 | } 60 | 61 | /** 62 | * @param OutputInterface $output 63 | * @param string|null $message 64 | * 65 | * @return int 66 | */ 67 | protected function outputSkip(OutputInterface $output, ?string $message = null): int 68 | { 69 | $message = $message ?: 'No relevant files found to process, free pass!'; 70 | $output->writeln('' . $message . ''); 71 | 72 | return 0; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Command/AddConfigCommand.php: -------------------------------------------------------------------------------- 1 | addMoodleOption($this) 33 | ->setName('add-config') 34 | ->setDescription('Add a line to the Moodle config.php file') 35 | ->addArgument('line', InputArgument::REQUIRED, 'Line of PHP code to add to the Moodle config.php file'); 36 | } 37 | 38 | protected function initialize(InputInterface $input, OutputInterface $output): void 39 | { 40 | $this->initializeMoodle($input); 41 | } 42 | 43 | protected function execute(InputInterface $input, OutputInterface $output): int 44 | { 45 | $line = $input->getArgument('line'); 46 | $file = $this->moodle->directory . '/config.php'; 47 | 48 | $config = new MoodleConfig(); 49 | $contents = $config->read($file); 50 | $contents = $config->injectLine($contents, $line); 51 | $config->dump($file, $contents); 52 | 53 | $output->writeln('Updated Moodle config.php file with the following line:'); 54 | $output->writeln(['', $line, '']); 55 | 56 | return $this->lintFile($file, $output); 57 | } 58 | 59 | /** 60 | * Check a single file for PHP syntax errors. 61 | * 62 | * @param string $file Path to the file to lint 63 | * @param OutputInterface $output 64 | * 65 | * @return int 66 | */ 67 | public function lintFile(string $file, OutputInterface $output): int 68 | { 69 | $manager = new Manager(); 70 | $settings = new Settings(); 71 | $settings->addPaths([$file]); 72 | 73 | ob_start(); 74 | $result = $manager->run($settings); 75 | $buffer = ob_get_contents(); 76 | ob_end_clean(); 77 | 78 | if ($result->hasError()) { 79 | $output->writeln('Syntax error was found in config.php after it was updated.'); 80 | $output->writeln(['Review the PHP Lint report for more details:', '', $buffer]); 81 | } 82 | 83 | return $result->hasError() ? 1 : 0; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Command/AddPluginCommand.php: -------------------------------------------------------------------------------- 1 | envFile = $envFile; 37 | } 38 | 39 | protected function configure(): void 40 | { 41 | $this->setName('add-plugin') 42 | ->setDescription('Queue up an additional plugin to be installed in the test site') 43 | ->addArgument('project', InputArgument::OPTIONAL, 'GitHub project, EG: moodlehq/moodle-local_hub, can\'t be used with --clone option') 44 | ->addOption('branch', 'b', InputOption::VALUE_REQUIRED, 'The branch to checkout in plugin repo (if non-default)', null) 45 | ->addOption('clone', 'c', InputOption::VALUE_REQUIRED, 'Git clone URL, can\'t be used with --project option') 46 | ->addOption('storage', null, InputOption::VALUE_REQUIRED, 'Plugin storage directory', 'moodle-plugin-ci-plugins'); 47 | } 48 | 49 | protected function initialize(InputInterface $input, OutputInterface $output): void 50 | { 51 | $this->initializeExecute($output, $this->getHelper('process')); 52 | } 53 | 54 | protected function execute(InputInterface $input, OutputInterface $output): int 55 | { 56 | $validate = new Validate(); 57 | $filesystem = new Filesystem(); 58 | $project = $input->getArgument('project'); 59 | $branch = $input->getOption('branch'); 60 | $clone = $input->getOption('clone'); 61 | $storage = $input->getOption('storage'); 62 | 63 | if (!empty($project) && !empty($clone)) { 64 | throw new \InvalidArgumentException('Cannot use both the project argument and the --clone option'); 65 | } 66 | if (!empty($project)) { 67 | $cloneUrl = sprintf('https://github.com/%s.git', $project); 68 | } elseif (!empty($clone)) { 69 | $cloneUrl = $clone; 70 | } else { 71 | throw new \RuntimeException('Must use the project argument or --clone option'); 72 | } 73 | 74 | $filesystem->mkdir($storage); 75 | $storageDir = realpath($validate->directory($storage)); 76 | 77 | $branchCmd = []; 78 | if (null !== $branch) { 79 | $branchCmd = [ 80 | '--branch', 81 | $branch, 82 | ]; 83 | } 84 | 85 | $cloneCmd = array_merge( 86 | [ 87 | 'git', 88 | 'clone', 89 | '--depth', 90 | '1', 91 | ], 92 | $branchCmd, 93 | [ 94 | $cloneUrl, 95 | ] 96 | ); 97 | $process = $this->execute->mustRun(new Process($cloneCmd, $storageDir, null, null, null)); 98 | 99 | $dumper = new EnvDumper(); 100 | $dumper->dump(['EXTRA_PLUGINS_DIR' => $storageDir], $this->envFile); 101 | 102 | return $process->isSuccessful() ? 0 : 1; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Command/CodeCheckerCommand.php: -------------------------------------------------------------------------------- 1 | setName('phpcs') 39 | ->setAliases(['codechecker']) 40 | ->setDescription('Run Moodle CodeSniffer standard on a plugin') 41 | ->addOption( 42 | 'standard', 43 | 's', 44 | InputOption::VALUE_REQUIRED, 45 | 'The name or path of the coding standard to use', 46 | 'moodle' 47 | )->addOption( 48 | 'exclude', 49 | 'x', 50 | InputOption::VALUE_REQUIRED, 51 | 'Comma separated list of sniff codes to exclude from checking', 52 | '' 53 | )->addOption( 54 | 'max-warnings', 55 | null, 56 | InputOption::VALUE_REQUIRED, 57 | 'Number of warnings to trigger nonzero exit code - default: -1', 58 | -1 59 | )->addOption( 60 | 'test-version', 61 | null, 62 | InputOption::VALUE_REQUIRED, 63 | 'Version or range of version to test with PHPCompatibility', 64 | 0 65 | )->addOption( 66 | 'todo-comment-regex', 67 | null, 68 | InputOption::VALUE_REQUIRED, 69 | 'Regex to use to match TODO/@todo comments', 70 | '' 71 | )->addOption( 72 | 'license-regex', 73 | null, 74 | InputOption::VALUE_REQUIRED, 75 | 'Regex to use to match @license tags', 76 | '' 77 | ); 78 | } 79 | 80 | protected function initialize(InputInterface $input, OutputInterface $output): void 81 | { 82 | parent::initialize($input, $output); 83 | $this->initializeExecute($output, $this->getHelper('process')); 84 | $this->tempFile = sys_get_temp_dir() . '/moodle-plugin-ci-code-checker-summary-' . time(); 85 | } 86 | 87 | protected function execute(InputInterface $input, OutputInterface $output): int 88 | { 89 | $this->outputHeading($output, 'Moodle CodeSniffer standard on %s'); 90 | 91 | $files = $this->plugin->getFiles(Finder::create()->name('*.php')); 92 | if (count($files) === 0) { 93 | return $this->outputSkip($output); 94 | } 95 | 96 | $filesystem = new Filesystem(); 97 | $pathToPHPCS = __DIR__ . '/../../vendor/squizlabs/php_codesniffer/bin/phpcs'; 98 | $pathToConf = __DIR__ . '/../../vendor/squizlabs/php_codesniffer/CodeSniffer.conf'; 99 | $basicCMD = ['php', $pathToPHPCS]; 100 | // If we are running phpcs within a PHAR, the command is different, and we need also to copy the .conf file. 101 | // @codeCoverageIgnoreStart 102 | // (This is not executed when running tests, only when within a PHAR) 103 | if (\Phar::running() !== '') { 104 | // Invoke phpcs from the PHAR (via include, own params after --). 105 | $basicCMD = ['php', '-r', 'include "' . $pathToPHPCS . '";', '--']; 106 | // Copy the .conf file to the directory where the PHAR is running. That way phpcs will find it. 107 | $targetPathToConf = dirname(\Phar::running(false)) . '/CodeSniffer.conf'; 108 | $filesystem->copy($pathToConf, $targetPathToConf, true); 109 | } 110 | // @codeCoverageIgnoreEnd 111 | 112 | $exclude = $input->getOption('exclude'); 113 | 114 | $cmd = array_merge($basicCMD, [ 115 | '--standard=' . ($input->getOption('standard') ?: 'moodle'), 116 | '--extensions=php', 117 | '-p', 118 | '-w', 119 | '-s', 120 | '--no-cache', 121 | empty($exclude) ? '' : ('--exclude=' . $exclude), 122 | $output->isDecorated() ? '--colors' : '--no-colors', 123 | '--report-full', 124 | '--report-width=132', 125 | '--encoding=utf-8', 126 | ]); 127 | 128 | // If we aren't using the max-warnings option, then we can forget about warnings and tell phpcs 129 | // to ignore them for exit-code purposes (still they will be reported in the output). 130 | if ($input->getOption('max-warnings') < 0) { 131 | array_push($cmd, '--runtime-set', 'ignore_warnings_on_exit', '1'); 132 | } else { 133 | // If we are using the max-warnings option, we need the summary report somewhere to get 134 | // the total number of errors and warnings from there. 135 | $cmd[] = '--report-json=' . $this->tempFile; 136 | } 137 | 138 | // Show PHPCompatibility backward-compatibility errors for a version or version range. 139 | $testVersion = $input->getOption('test-version'); 140 | if (!empty($testVersion)) { 141 | array_push($cmd, '--runtime-set', 'testVersion', $testVersion); 142 | } 143 | 144 | // Set the regex to use to match TODO/@todo comments. 145 | // Note that the option defaults to an empty string, 146 | // meaning that no checks will be performed. Configure it 147 | // to a valid regex ('MDL-[0-9]+', 'https:', ...) to enable the checks. 148 | $todoCommentRegex = $input->getOption('todo-comment-regex'); 149 | array_push($cmd, '--runtime-set', 'moodleTodoCommentRegex', $todoCommentRegex); 150 | 151 | // Set the regex to use to match @license tags. 152 | // Note that the option defaults to an empty string, 153 | // meaning that no checks will be performed. Configure it 154 | // to a valid regex ('GPL-3.0', 'https:', ...) to enable the checks. 155 | $licenseRegex = $input->getOption('license-regex'); 156 | array_push($cmd, '--runtime-set', 'moodleLicenseRegex', $licenseRegex); 157 | 158 | // Add the files to process. 159 | foreach ($files as $file) { 160 | $cmd[] = $file; 161 | } 162 | 163 | $process = $this->execute->passThroughProcess(new Process($cmd, $this->plugin->directory, null, null, null)); 164 | 165 | // If we are running phpcs within a PHAR, we need to remove the previously copied conf file. 166 | // @codeCoverageIgnoreStart 167 | // (This is not executed when running tests, only when within a PHAR) 168 | if (\Phar::running() !== '') { 169 | $targetPathToConf = dirname(\Phar::running(false)) . '/CodeSniffer.conf'; 170 | $filesystem->remove($targetPathToConf); 171 | } 172 | // @codeCoverageIgnoreEnd 173 | 174 | // If we aren't using the max-warnings option, process exit code is enough for us. 175 | if ($input->getOption('max-warnings') < 0) { 176 | return $process->isSuccessful() ? 0 : 1; 177 | } 178 | 179 | // Arrived here, we are playing with max-warnings, so we have to decide the exit code 180 | // based on the existence of errors and the number of warnings compared with the threshold. 181 | 182 | // Verify that the summary file was created. If not, something went wrong with the execution. 183 | if (!file_exists($this->tempFile)) { 184 | return 1; 185 | } 186 | 187 | // Let's inspect the summary file to get the total number of errors and warnings. 188 | $totalErrors = 0; 189 | $totalWarnings = 0; 190 | $jsonFile = trim(file_get_contents($this->tempFile)); 191 | if ($json = json_decode($jsonFile, false)) { 192 | $totalErrors = (int) $json->totals->errors; 193 | $totalWarnings = (int) $json->totals->warnings; 194 | } 195 | (new Filesystem())->remove($this->tempFile); // Remove the temporal summary file. 196 | 197 | // With errors or warnings over the max-warnings threshold, fail the command. 198 | return ($totalErrors > 0 || ($totalWarnings > $input->getOption('max-warnings'))) ? 1 : 0; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/Command/CodeFixerCommand.php: -------------------------------------------------------------------------------- 1 | setName('phpcbf') 32 | ->setAliases(['codefixer']) 33 | ->setDescription('Run Code Beautifier and Fixer on a plugin') 34 | ->addOption('standard', 's', InputOption::VALUE_REQUIRED, 'The name or path of the coding standard to use', 'moodle'); 35 | } 36 | 37 | protected function execute(InputInterface $input, OutputInterface $output): int 38 | { 39 | $this->outputHeading($output, 'Code Beautifier and Fixer on %s'); 40 | 41 | $files = $this->plugin->getFiles(Finder::create()->name('*.php')); 42 | if (count($files) === 0) { 43 | return $this->outputSkip($output); 44 | } 45 | 46 | $filesystem = new Filesystem(); 47 | $pathToPHPCBF = __DIR__ . '/../../vendor/squizlabs/php_codesniffer/bin/phpcbf'; 48 | $pathToConf = __DIR__ . '/../../vendor/squizlabs/php_codesniffer/CodeSniffer.conf'; 49 | $basicCMD = ['php', $pathToPHPCBF]; 50 | // If we are running phpcs within a PHAR, the command is different, and we need also to copy the .conf file. 51 | // @codeCoverageIgnoreStart 52 | // (This is not executed when running tests, only when within a PHAR) 53 | if (\Phar::running() !== '') { 54 | // Invoke phpcbf from the PHAR (via include, own params after --). 55 | $basicCMD = ['php', '-r', 'include "' . $pathToPHPCBF . '";', '--']; 56 | // Copy the .conf file to the directory where the PHAR is running. That way phpcbf will find it. 57 | $targetPathToConf = dirname(\Phar::running(false)) . '/CodeSniffer.conf'; 58 | $filesystem->copy($pathToConf, $targetPathToConf, true); 59 | } 60 | // @codeCoverageIgnoreEnd 61 | 62 | $cmd = array_merge($basicCMD, [ 63 | '--standard=' . ($input->getOption('standard') ?: 'moodle'), 64 | '--extensions=php', 65 | '-p', 66 | '-w', 67 | '-s', 68 | '--no-cache', 69 | $output->isDecorated() ? '--colors' : '--no-colors', 70 | '--report-full', 71 | '--report-width=132', 72 | '--encoding=utf-8', 73 | ]); 74 | 75 | // Add the files to process. 76 | foreach ($files as $file) { 77 | $cmd[] = $file; 78 | } 79 | 80 | $this->execute->passThroughProcess(new Process($cmd, $this->plugin->directory, null, null, null)); 81 | 82 | // If we are running phpcbf within a PHAR, we need to remove the previously copied conf file. 83 | // @codeCoverageIgnoreStart 84 | // (This is not executed when running tests, only when within a PHAR) 85 | if (\Phar::running() !== '') { 86 | $targetPathToConf = dirname(\Phar::running(false)) . '/CodeSniffer.conf'; 87 | $filesystem->remove($targetPathToConf); 88 | } 89 | // @codeCoverageIgnoreEnd 90 | 91 | return 0; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Command/CopyPasteDetectorCommand.php: -------------------------------------------------------------------------------- 1 | setName('phpcpd') 36 | ->setDescription('Run PHP Copy/Paste Detector on a plugin (**DEPRECATED**)'); 37 | } 38 | 39 | protected function execute(InputInterface $input, OutputInterface $output): int 40 | { 41 | // @codeCoverageIgnoreStart 42 | if (!defined('PHPUNIT_TEST')) { // Only show deprecation warnings in non-test environments. 43 | trigger_deprecation( 44 | 'moodle-plugin-ci', 45 | '4,4,0', 46 | 'The "%s" command is deprecated and will be removed in %s. No replacement is planned.', 47 | $this->getName(), 48 | '5.0.0' 49 | ); 50 | if (getenv('GITHUB_ACTIONS')) { // Only show deprecation annotations in GitHub Actions. 51 | echo '::warning title=Deprecated command::The phpcpd command ' . 52 | 'is deprecated and will be removed in 5.0.0. No replacement is planned.' . PHP_EOL; 53 | } 54 | } 55 | // @codeCoverageIgnoreEnd 56 | 57 | $timer = new Timer(); 58 | $timer->start(); 59 | 60 | $this->outputHeading($output, 'PHP Copy/Paste Detector on %s'); 61 | 62 | $files = $this->plugin->getFiles(Finder::create()->name('*.php')); 63 | if (count($files) === 0) { 64 | return $this->outputSkip($output); 65 | } 66 | $detector = new Detector(new DefaultStrategy()); 67 | $clones = $detector->copyPasteDetection($files); 68 | 69 | $printer = new Text(); 70 | $printer->printResult($clones, true); 71 | $output->writeln((new ResourceUsageFormatter())->resourceUsage($timer->stop())); 72 | 73 | return count($clones) > 0 ? 1 : 0; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Command/CoverallsUploadCommand.php: -------------------------------------------------------------------------------- 1 | setName('coveralls-upload') 33 | ->setDescription('Upload code coverage to Coveralls') 34 | ->addOption('coverage-file', null, InputOption::VALUE_REQUIRED, 'Location of the Clover XML file to upload', './coverage.xml'); 35 | } 36 | 37 | protected function initialize(InputInterface $input, OutputInterface $output): void 38 | { 39 | parent::initialize($input, $output); 40 | $this->initializeExecute($output, $this->getHelper('process')); 41 | } 42 | 43 | protected function execute(InputInterface $input, OutputInterface $output): int 44 | { 45 | $coverage = realpath($input->getOption('coverage-file')); 46 | if ($coverage === false) { 47 | $message = sprintf('Did not find coverage file at %s', $input->getOption('coverage-file')); 48 | $output->writeln($message); 49 | 50 | return 0; 51 | } 52 | 53 | $filesystem = new Filesystem(); 54 | 55 | // Only if it has not been installed before. 56 | if (!$filesystem->exists($this->plugin->directory . '/coveralls')) { 57 | $cmd = [ 58 | 'composer', 59 | 'create-project', 60 | '-n', 61 | '--no-dev', 62 | '--prefer-dist', 63 | 'php-coveralls/php-coveralls', 64 | 'coveralls', 65 | '^2', 66 | ]; 67 | $process = new Process($cmd, $this->plugin->directory); 68 | $this->execute->mustRun($process); 69 | } 70 | 71 | // Yes, this is a hack, but it's the only way to get the coverage file into the right place 72 | // for the coveralls command to find it. 73 | $filesystem->copy($coverage, $this->plugin->directory . '/build/logs/clover.xml'); 74 | 75 | $cmd = [ 76 | 'coveralls/bin/php-coveralls', 77 | '-v', 78 | ]; 79 | $process = $this->execute->passThrough($cmd, $this->plugin->directory); 80 | 81 | return $process->isSuccessful() ? 0 : 1; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Command/ExecuteTrait.php: -------------------------------------------------------------------------------- 1 | execute)) { 35 | // Define output and process helper. 36 | $this->execute->setOutput($output); 37 | $this->execute->setHelper($helper); 38 | } else { 39 | $this->execute = new Execute($output, $helper); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Command/GruntCommand.php: -------------------------------------------------------------------------------- 1 | setName('grunt') 39 | ->setDescription('Run Grunt task on a plugin') 40 | ->addOption('tasks', 't', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The Grunt tasks to run', $tasks) 41 | ->addOption('show-lint-warnings', null, InputOption::VALUE_NONE, 'Show eslint warnings') 42 | ->addOption('max-lint-warnings', null, InputOption::VALUE_REQUIRED, 'Maximum number of eslint warnings', ''); 43 | } 44 | 45 | protected function initialize(InputInterface $input, OutputInterface $output): void 46 | { 47 | parent::initialize($input, $output); 48 | $this->initializeExecute($output, $this->getHelper('process')); 49 | $this->backupDir = $this->backupDir ?? sys_get_temp_dir() . '/moodle-plugin-ci-grunt-backup-' . time(); 50 | } 51 | 52 | protected function execute(InputInterface $input, OutputInterface $output): int 53 | { 54 | $this->outputHeading($output, 'Grunt on %s'); 55 | $this->backupPlugin(); 56 | 57 | $code = 0; 58 | $files = new Filesystem(); 59 | $tasks = $input->getOption('tasks'); 60 | 61 | foreach ($tasks as $taskName) { 62 | $task = $this->toGruntTask($taskName); 63 | if ($task === null) { 64 | continue; // Means plugin lacks requirements or Moodle does. 65 | } 66 | 67 | $cmd = [ 68 | 'npx', 'grunt', 69 | $task->taskName, 70 | ]; 71 | 72 | if ($input->getOption('show-lint-warnings')) { 73 | $cmd[] = '--show-lint-warnings'; 74 | } 75 | 76 | if (strlen($input->getOption('max-lint-warnings'))) { 77 | $cmd[] = '--max-lint-warnings=' . ((int) $input->getOption('max-lint-warnings')); 78 | } 79 | 80 | // Remove build directory, so we can detect files that should be deleted. 81 | if (!empty($task->buildDirectory)) { 82 | $files->remove($this->plugin->directory . '/' . $task->buildDirectory); 83 | } 84 | 85 | $process = $this->execute->passThroughProcess(new Process($cmd, $task->workingDirectory, null, null, null)); 86 | 87 | if (!$process->isSuccessful()) { 88 | $code = 1; 89 | } 90 | } 91 | 92 | if ($code === 0) { 93 | $code = $this->validatePluginFiles($output); 94 | } 95 | 96 | $this->restorePlugin(); 97 | (new Filesystem())->remove($this->backupDir); 98 | 99 | return $code; 100 | } 101 | 102 | /** 103 | * Backup the plugin so we can use it for comparison and restores. 104 | */ 105 | public function backupPlugin(): void 106 | { 107 | (new Filesystem())->mirror($this->plugin->directory, $this->backupDir); 108 | } 109 | 110 | /** 111 | * Revert any changes Grunt tasks might have done. 112 | */ 113 | public function restorePlugin(): void 114 | { 115 | (new Filesystem())->mirror($this->backupDir, $this->plugin->directory, null, ['delete' => true, 'override' => true]); 116 | } 117 | 118 | /** 119 | * Verify that no plugin files were modified, need to be deleted or were added. 120 | * 121 | * Only checks JS and CSS files. 122 | * 123 | * @param OutputInterface $output 124 | * 125 | * @return int 126 | */ 127 | public function validatePluginFiles(OutputInterface $output): int 128 | { 129 | $code = 0; 130 | 131 | // Look for modified files or files that should be deleted. 132 | $files = Finder::create()->files()->in($this->backupDir)->name('*.js')->name('*.js.map')->name('*.css')->getIterator(); 133 | foreach ($files as $file) { 134 | $compareFile = $this->plugin->directory . '/' . $file->getRelativePathname(); 135 | if (!file_exists($compareFile)) { 136 | $output->writeln(sprintf('File no longer generated and likely should be deleted: %s', $file->getRelativePathname())); 137 | $code = 1; 138 | continue; 139 | } 140 | 141 | if (sha1_file($file->getPathname()) !== sha1_file($compareFile)) { 142 | $output->writeln(sprintf('File is stale and needs to be rebuilt: %s', $file->getRelativePathname())); 143 | $code = 1; 144 | } 145 | } 146 | 147 | // Look for newly generated files. 148 | $files = Finder::create()->files()->in($this->plugin->directory)->name('*.js')->name('*.js.map')->name('*.css')->getIterator(); 149 | foreach ($files as $file) { 150 | if (!file_exists($this->backupDir . '/' . $file->getRelativePathname())) { 151 | $output->writeln(sprintf('File is newly generated and needs to be added: %s', $file->getRelativePathname())); 152 | $code = 1; 153 | } 154 | } 155 | 156 | return $code; 157 | } 158 | 159 | /** 160 | * Create a Grunt Task Model based on the task we are trying to run. 161 | * 162 | * @param string $task 163 | * 164 | * @return GruntTaskModel|null 165 | */ 166 | public function toGruntTask(string $task): ?GruntTaskModel 167 | { 168 | $workingDirectory = $this->moodle->directory; 169 | if (is_file($this->plugin->directory . '/Gruntfile.js')) { 170 | $workingDirectory = $this->plugin->directory; 171 | } 172 | $defaultTask = new GruntTaskModel($task, $workingDirectory); 173 | $defaultTaskPluginDir = new GruntTaskModel($task, $this->plugin->directory); 174 | 175 | switch ($task) { 176 | case 'amd': 177 | $amdDir = $this->plugin->directory . '/amd'; 178 | if (!is_dir($amdDir)) { 179 | return null; 180 | } 181 | 182 | return new GruntTaskModel($task, $amdDir, 'amd/build'); 183 | case 'shifter': 184 | case 'yui': 185 | $yuiDir = $this->plugin->directory . '/yui/src'; 186 | if (!is_dir($yuiDir)) { 187 | return null; 188 | } 189 | 190 | return new GruntTaskModel($task, $yuiDir, 'yui/build'); 191 | case 'gherkinlint': 192 | if ($this->moodle->getBranch() < 33 || !$this->plugin->hasBehatFeatures()) { 193 | return null; 194 | } 195 | 196 | return new GruntTaskModel($task, $this->moodle->directory); 197 | case 'stylelint': 198 | // Let stylelint task logic to determine which type of linter to run. 199 | return $this->plugin->hasFilesWithName('*.css') || $this->plugin->hasFilesWithName('*.scss') ? $defaultTaskPluginDir : null; 200 | case 'stylelint:css': 201 | return $this->plugin->hasFilesWithName('*.css') ? $defaultTaskPluginDir : null; 202 | case 'stylelint:scss': 203 | return $this->plugin->hasFilesWithName('*.scss') ? $defaultTaskPluginDir : null; 204 | default: 205 | return $defaultTask; 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/Command/MessDetectorCommand.php: -------------------------------------------------------------------------------- 1 | setName('phpmd') 35 | ->setDescription('Run PHP Mess Detector on a plugin') 36 | ->addOption('rules', 'r', InputOption::VALUE_REQUIRED, 'Path to PHP Mess Detector rule set'); 37 | } 38 | 39 | protected function execute(InputInterface $input, OutputInterface $output): int 40 | { 41 | $this->outputHeading($output, 'PHP Mess Detector on %s'); 42 | 43 | $files = $this->plugin->getFiles(Finder::create()->name('*.php')); 44 | if (count($files) === 0) { 45 | return $this->outputSkip($output); 46 | } 47 | $rules = $input->getOption('rules') ?: __DIR__ . '/../../res/config/phpmd.xml'; 48 | 49 | $renderer = new MessDetectorRenderer($output, $this->moodle->directory); 50 | $renderer->setWriter(new StreamWriter(STDOUT)); 51 | 52 | $ruleSetFactory = new RuleSetFactory(); 53 | $ruleSetFactory->setMinimumPriority(5); 54 | $ruleSets = $ruleSetFactory->createRuleSets($rules); 55 | 56 | $messDetector = new PHPMD(); 57 | $messDetector->processFiles( 58 | implode(',', $files), 59 | [], // Ignored paths and files are managed by the plugin, so they are not needed here. 60 | [$renderer], 61 | $ruleSets, 62 | new Report(), 63 | ); 64 | 65 | return 0; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Command/MoodleOptionTrait.php: -------------------------------------------------------------------------------- 1 | addOption('moodle', 'm', InputOption::VALUE_REQUIRED, 'Path to Moodle', $moodle); 39 | 40 | return $command; 41 | } 42 | 43 | /** 44 | * Initialize the moodle property based on input if necessary. 45 | * 46 | * @param InputInterface $input 47 | */ 48 | protected function initializeMoodle(InputInterface $input): void 49 | { 50 | if (!isset($this->moodle)) { 51 | $validate = new Validate(); 52 | $moodleDir = realpath($validate->directory($input->getOption('moodle'))); 53 | $this->moodle = new Moodle($moodleDir); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Command/MustacheCommand.php: -------------------------------------------------------------------------------- 1 | setName('mustache') 33 | ->setDescription('Run Mustache Lint on a plugin'); 34 | } 35 | 36 | protected function initialize(InputInterface $input, OutputInterface $output): void 37 | { 38 | parent::initialize($input, $output); 39 | $this->initializeExecute($output, $this->getHelper('process')); 40 | } 41 | 42 | protected function execute(InputInterface $input, OutputInterface $output): int 43 | { 44 | $this->outputHeading($output, 'Mustache Lint on %s'); 45 | 46 | $files = $this->plugin->getFiles(Finder::create()->name('*.mustache')); 47 | if (count($files) === 0) { 48 | return $this->outputSkip($output); 49 | } 50 | 51 | $linter = __DIR__ . '/../../vendor/moodlehq/moodle-local_ci/mustache_lint/mustache_lint.php'; 52 | $jarFile = $this->resolveJarFile(); 53 | 54 | // This is a workaround to execute mustache_lint.php file from within a phar. 55 | // (by copying both the script and the jar file to a temporary directory) 56 | $filesystem = new Filesystem(); 57 | $tmpDir = sys_get_temp_dir(); 58 | $wrapper = tempnam($tmpDir, 'mustache-linter-wrapper'); 59 | $jarTmpFile = $tmpDir . '/vnu.jar'; 60 | $filesystem->dumpFile($wrapper, sprintf('copy($jarFile, $jarTmpFile, true); 62 | 63 | $code = 0; 64 | foreach ($files as $file) { 65 | $cmd = [ 66 | 'env', 67 | '-u', 68 | // _JAVA_OPTIONS is something Travis CI started to set in Trusty. This breaks Mustache because 69 | // the output from vnu.jar needs to be captured and JSON decoded. When _JAVA_OPTIONS is present, 70 | // then a message like "Picked up _JAVA_OPTIONS..." is printed which breaks JSON decoding. 71 | '_JAVA_OPTIONS', 72 | 'php', 73 | $wrapper, 74 | '--filename=' . $file, 75 | '--validator=' . $jarTmpFile, 76 | '--basename=' . $this->moodle->directory, 77 | ]; 78 | // _JAVA_OPTIONS is something Travis CI started to set in Trusty. This breaks Mustache because 79 | // the output from vnu.jar needs to be captured and JSON decoded. When _JAVA_OPTIONS is present, 80 | // then a message like "Picked up _JAVA_OPTIONS..." is printed which breaks JSON decoding. 81 | $process = $this->execute->passThroughProcess(new Process($cmd, $this->moodle->directory, null, null, null)); 82 | 83 | if (!$process->isSuccessful()) { 84 | $code = 1; 85 | } 86 | } 87 | 88 | $filesystem->remove($wrapper); 89 | $filesystem->remove($jarTmpFile); 90 | 91 | return $code; 92 | } 93 | 94 | /** 95 | * @return string 96 | */ 97 | private function resolveJarFile(): string 98 | { 99 | // Check if locally installed. 100 | $file = __DIR__ . '/../../vendor/moodlehq/moodle-local_ci/node_modules/vnu-jar/build/dist/vnu.jar'; 101 | if (is_file($file)) { 102 | // No need to use realpath() when running from a phar. 103 | return (\Phar::running() !== '') ? $file : realpath($file); 104 | } 105 | 106 | // Check for global install. 107 | $this->validateJarVersion(); 108 | 109 | $cmd = [ 110 | 'npm', 111 | '-g', 112 | 'prefix', 113 | ]; 114 | $process = $this->execute->mustRun($cmd); 115 | $file = trim($process->getOutput()) . '/lib/node_modules/vnu-jar/build/dist/vnu.jar'; 116 | 117 | if (!is_file($file)) { 118 | throw new \RuntimeException(sprintf('Failed to find %s', $file)); 119 | } 120 | 121 | return $file; 122 | } 123 | 124 | private function validateJarVersion(): void 125 | { 126 | $cmd = [ 127 | 'npm', 128 | '-g', 129 | 'list', 130 | '--json', 131 | ]; 132 | $json = json_decode($this->execute->mustRun($cmd)->getOutput(), true); 133 | if (!isset($json['dependencies']['vnu-jar']['version'])) { 134 | throw new \RuntimeException('Failed to find vnu-jar'); 135 | } 136 | $version = $json['dependencies']['vnu-jar']['version']; 137 | if (!version_compare($version, '17.3.0', '>=') && !version_compare($version, '18.0.0', '<')) { 138 | throw new \RuntimeException('Global install of vnu-jar does not match version constraints: vnu-jar@>=17.3.0 <18.0.0'); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/Command/PHPDocCommand.php: -------------------------------------------------------------------------------- 1 | setName('phpdoc') 34 | ->setDescription('Run Moodle PHPDoc Checker on a plugin') 35 | ->addOption('max-warnings', null, InputOption::VALUE_REQUIRED, 36 | 'Number of warnings to trigger nonzero exit code - default: -1', -1); 37 | } 38 | 39 | protected function initialize(InputInterface $input, OutputInterface $output): void 40 | { 41 | parent::initialize($input, $output); 42 | $this->initializeExecute($output, $this->getHelper('process')); 43 | $this->finder = Finder::create()->name('*.php'); 44 | } 45 | 46 | protected function execute(InputInterface $input, OutputInterface $output): int 47 | { 48 | $this->outputHeading($output, 'Moodle PHPDoc Checker on %s'); 49 | 50 | // We need local_moodlecheck plugin to run this check. 51 | $pluginlocation = __DIR__ . '/../../vendor/moodlehq/moodle-local_moodlecheck'; 52 | $plugin = new MoodlePlugin($pluginlocation); 53 | $directory = $this->moodle->getComponentInstallDirectory($plugin->getComponent()); 54 | if (!is_dir($directory)) { 55 | // Copy plugin into Moodle if it does not exist. 56 | $filesystem = new Filesystem(); 57 | $filesystem->mirror($plugin->directory, $directory); 58 | } 59 | 60 | $files = $this->plugin->getFiles($this->finder); 61 | if (count($files) === 0) { 62 | return $this->outputSkip($output); 63 | } 64 | 65 | $cmd = [ 66 | 'php', 67 | 'local/moodlecheck/cli/moodlecheck.php', 68 | '-p=' . implode(',', $files), 69 | '-f=text', 70 | ]; 71 | 72 | $process = $this->execute->passThroughProcess(new Process($cmd, $this->moodle->directory, null, null, null)); 73 | 74 | if (isset($filesystem)) { 75 | // Remove plugin if we added it, so we leave things clean. 76 | $filesystem->remove($directory); 77 | } 78 | 79 | // moodlecheck.php does not return valid exit status, 80 | // We have to parse output to see if there are errors and/or warnings. 81 | $results = $process->getOutput(); 82 | $totalProblems = (int) preg_match_all('~^\\s+Line~m', $results); 83 | $totalWarnings = (int) preg_match_all('~\(warning\)$~m', $results); 84 | $totalErrors = $totalProblems - $totalWarnings; 85 | $pseudoExitCode = ($totalErrors > 0) ? 1 : 0; // Calculate exit code based on # errors by default. 86 | 87 | // If we aren't using the max-warnings option, (pseudo) process exit code is enough for us. 88 | if ($input->getOption('max-warnings') < 0) { 89 | return $pseudoExitCode; 90 | } 91 | 92 | // With errors or warnings over the max-warnings threshold, fail the command. 93 | return ($totalErrors > 0 || ($totalWarnings > $input->getOption('max-warnings'))) ? 1 : 0; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Command/PHPLintCommand.php: -------------------------------------------------------------------------------- 1 | setName('phplint') 31 | ->setDescription('Run PHP Lint on a plugin'); 32 | } 33 | 34 | protected function execute(InputInterface $input, OutputInterface $output): int 35 | { 36 | $this->outputHeading($output, 'PHP Lint on %s'); 37 | 38 | $files = $this->plugin->getFiles(Finder::create()->name('*.php')); 39 | if (count($files) === 0) { 40 | return $this->outputSkip($output); 41 | } 42 | 43 | $settings = new Settings(); 44 | $settings->addPaths($files); 45 | 46 | $manager = new Manager(); 47 | try { 48 | $result = $manager->run($settings); 49 | } catch (\Exception $e) { 50 | return 1; 51 | } 52 | 53 | return $result->hasError() ? 1 : 0; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Command/ParallelCommand.php: -------------------------------------------------------------------------------- 1 | setName('parallel') 38 | ->setDescription('Run all of the tests and analysis against a plugin'); 39 | } 40 | 41 | protected function initialize(InputInterface $input, OutputInterface $output): void 42 | { 43 | parent::initialize($input, $output); 44 | 45 | $this->processes = $this->processes ?: $this->initializeProcesses(); 46 | } 47 | 48 | protected function execute(InputInterface $input, OutputInterface $output): int 49 | { 50 | $this->outputHeading($output, 'All checks in parallel on %s (output will show below)'); 51 | 52 | $this->runProcesses($output); 53 | 54 | return $this->reportOnProcesses($input, $output); 55 | } 56 | 57 | /** 58 | * @return Process[][] 59 | */ 60 | public function initializeProcesses(): array 61 | { 62 | $bin = ['php', $_SERVER['PHP_SELF'] ?? 'moodle-plugin-ci']; 63 | $plugin = $this->plugin->directory; 64 | $moodle = $this->moodle->directory; 65 | 66 | // Note that we cannot run them 100% in parallel, because some of them install and remove 67 | // code from the moodle checkout, and that may cause problems to other processes. Hence, we 68 | // have them grouped into parallel-safe groups. 69 | return [ 70 | [ 71 | // The 'savepoints' command installs and removes local/plugin/check_upgrade_savepoints.php. 72 | 'savepoints' => new Process(array_merge($bin, ['savepoints', '--ansi', $plugin])), 73 | // The 'phpdoc' command installs and removes local/moodlecheck. 74 | 'phpdoc' => new Process(array_merge($bin, ['phpdoc', '--ansi', $plugin])), 75 | ], 76 | [ 77 | 'phplint' => new Process(array_merge($bin, ['phplint', '--ansi', $plugin])), 78 | 'phpcpd' => new Process(array_merge($bin, ['phpcpd', '--ansi', $plugin])), 79 | 'phpmd' => new Process(array_merge($bin, ['phpmd', '--ansi', '-m', $moodle, $plugin])), 80 | 'codechecker' => new Process(array_merge($bin, ['codechecker', '--ansi', $plugin])), 81 | 'validate' => new Process(array_merge($bin, ['validate', '--ansi', '-m', $moodle, $plugin])), 82 | 'mustache' => new Process(array_merge($bin, ['mustache', '--ansi', '-m', $moodle, $plugin])), 83 | 'grunt' => new Process(array_merge($bin, ['grunt', '--ansi', '-m', $moodle, $plugin])), 84 | 'phpunit' => new Process(array_merge($bin, ['phpunit', '--ansi', '-m', $moodle, $plugin])), 85 | 'behat' => new Process(array_merge($bin, ['behat', '--ansi', '-m', $moodle, $plugin])), 86 | ], 87 | ]; 88 | } 89 | 90 | /** 91 | * Run the processes in parallel. 92 | * 93 | * @param OutputInterface $output 94 | */ 95 | private function runProcesses(OutputInterface $output): void 96 | { 97 | $progress = new ProgressIndicator($output); 98 | $progress->start('Starting...'); 99 | 100 | // Start all the processes, in groups of parallel-safe processes. 101 | foreach ($this->processes as $processGroup) { 102 | foreach ($processGroup as $name => $process) { 103 | $process->start(); 104 | $progress->advance(); 105 | } 106 | // Wait until the group is done before starting with the next group. 107 | foreach ($processGroup as $name => $process) { 108 | $progress->setMessage(sprintf('Waiting for moodle-plugin-ci %s...', $name)); 109 | while ($process->isRunning()) { 110 | $progress->advance(); 111 | } 112 | } 113 | } 114 | $progress->finish('Done!'); 115 | } 116 | 117 | /** 118 | * Report on the completed processes. 119 | * 120 | * @param InputInterface $input 121 | * @param OutputInterface $output 122 | * 123 | * @return int 124 | */ 125 | private function reportOnProcesses(InputInterface $input, OutputInterface $output): int 126 | { 127 | $style = new SymfonyStyle($input, $output); 128 | 129 | $result = 0; 130 | 131 | // Report the output of all the processes, in groups of parallel-safe processes. 132 | foreach ($this->processes as $processGroup) { 133 | foreach ($processGroup as $name => $process) { 134 | $style->newLine(); 135 | 136 | echo $process->getOutput(); 137 | 138 | if (!$process->isSuccessful()) { 139 | $result = 1; 140 | $style->error(sprintf('Command %s failed', $name)); 141 | } 142 | $errorOutput = $process->getErrorOutput(); 143 | if (!empty($errorOutput)) { 144 | $style->error(sprintf('Error output for %s command', $name)); 145 | $style->writeln($errorOutput); 146 | } 147 | } 148 | } 149 | 150 | return $result; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Command/SavePointsCommand.php: -------------------------------------------------------------------------------- 1 | setName('savepoints') 29 | ->setDescription('Check upgrade savepoints'); 30 | } 31 | 32 | protected function initialize(InputInterface $input, OutputInterface $output): void 33 | { 34 | parent::initialize($input, $output); 35 | $this->initializeExecute($output, $this->getHelper('process')); 36 | } 37 | 38 | protected function execute(InputInterface $input, OutputInterface $output): int 39 | { 40 | $this->outputHeading($output, 'Check upgrade savepoints on %s'); 41 | 42 | if (!is_file($this->plugin->directory . '/db/upgrade.php')) { 43 | return $this->outputSkip($output); 44 | } 45 | 46 | $filesystem = new Filesystem(); 47 | $upgradetester = __DIR__ . '/../../vendor/moodlehq/moodle-local_ci/check_upgrade_savepoints/check_upgrade_savepoints.php'; 48 | $filesystem->copy($upgradetester, $this->plugin->directory . '/check_upgrade_savepoints.php'); 49 | 50 | $process = $this->execute->passThroughProcess( 51 | (new Process(['php', 'check_upgrade_savepoints.php'])) 52 | ->setTimeout(null) 53 | ->setWorkingDirectory($this->plugin->directory) 54 | ); 55 | 56 | $code = 0; 57 | $results = $process->getOutput(); 58 | if (strstr($results, 'WARN') || strstr($results, 'ERROR')) { 59 | $code = 1; 60 | } 61 | 62 | $filesystem->remove($this->plugin->directory . '/check_upgrade_savepoints.php'); 63 | 64 | return $code; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Command/SelfUpdateCommand.php: -------------------------------------------------------------------------------- 1 | setName('selfupdate') 28 | ->setAliases(['self-update']) 29 | ->setDescription('Updates moodle-plugin-ci') 30 | ->addOption('rollback', 'r', InputOption::VALUE_NONE, 'Rollback to the last version') 31 | ->addOption('preview', null, InputOption::VALUE_NONE, 'Update to pre-release version') 32 | ->addOption('any', null, InputOption::VALUE_NONE, 'Update to most recent release'); 33 | } 34 | 35 | protected function execute(InputInterface $input, OutputInterface $output): int 36 | { 37 | $rollback = $input->getOption('rollback'); 38 | $stability = GithubStrategy::STABLE; 39 | 40 | if ($input->getOption('preview')) { 41 | $stability = GithubStrategy::UNSTABLE; 42 | } 43 | if ($input->getOption('any')) { 44 | $stability = GithubStrategy::ANY; 45 | } 46 | 47 | $application = $this->getApplication(); 48 | if (!isset($application)) { 49 | $output->writeln('Self update command failed!'); 50 | exit(1); 51 | } 52 | 53 | $strategy = new GithubStrategy(); 54 | $strategy->setPackageName('moodlehq/moodle-plugin-ci'); 55 | $strategy->setPharName('moodle-plugin-ci.phar'); 56 | $strategy->setCurrentLocalVersion($application->getVersion()); 57 | $strategy->setStability($stability); 58 | 59 | $path = $this->getBackupPath(); 60 | 61 | $updater = new Updater(null, false); 62 | $updater->setStrategyObject($strategy); 63 | $updater->setBackupPath($path); 64 | $updater->setRestorePath($path); 65 | 66 | // Note to self: after this point, do a LITTLE as possible because after the new Phar is in place 67 | // we cannot load any new files, etc. 68 | try { 69 | if ($rollback) { 70 | if ($updater->rollback()) { 71 | $output->writeln('Rollback successful!'); 72 | exit(0); 73 | } 74 | $output->writeln('Rollback failed!'); 75 | exit(1); 76 | } 77 | 78 | $result = $updater->update(); 79 | 80 | if ($result) { 81 | $output->writeln(''); 82 | $output->writeln(sprintf('Updated to version %s', $updater->getNewVersion())); 83 | $output->writeln(''); 84 | $output->writeln(sprintf('Use moodle-plugin-ci selfupdate --rollback to return to version %s', $updater->getOldVersion())); 85 | } else { 86 | $output->writeln('Already up-to-date.'); 87 | } 88 | exit(0); 89 | } catch (\Exception $e) { 90 | $output->writeln('Exception: ' . $e->getMessage() . PHP_EOL . $e->getTraceAsString()); 91 | exit(1); 92 | } 93 | } 94 | 95 | /** 96 | * Calculate the full path where the old PHAR file will be backup (to be able to roll back to it). 97 | * 98 | * @param string|null $directory the directory where the backup will be stored 99 | * 100 | * @return string 101 | */ 102 | protected function getBackupPath(?string $directory = null): string 103 | { 104 | $directory = $directory ?? getenv('HOME'); // Default to $HOME as base directory if not provided. 105 | if (empty($directory) || !is_dir($directory)) { 106 | throw new \RuntimeException("The {$directory} path is not an existing directory"); 107 | } 108 | $directory .= '/.moodle-plugin-ci'; 109 | 110 | (new Filesystem())->mkdir($directory); 111 | 112 | return $directory . '/moodle-plugin-ci-old.phar'; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Command/ValidateCommand.php: -------------------------------------------------------------------------------- 1 | setName('validate') 31 | ->setDescription('Validate a plugin'); 32 | } 33 | 34 | protected function execute(InputInterface $input, OutputInterface $output): int 35 | { 36 | $this->outputHeading($output, 'Validating %s'); 37 | 38 | list($type, $name) = $this->moodle->normalizeComponent($this->plugin->getComponent()); 39 | 40 | $plugin = new Plugin($this->plugin->getComponent(), $type, $name, $this->plugin->directory); 41 | $resolver = new RequirementsResolver(); 42 | $requirements = $resolver->resolveRequirements($plugin, $this->moodle->getBranch()); 43 | 44 | $validate = new PluginValidate($plugin, $requirements); 45 | $validate->verifyRequirements(); 46 | 47 | $output->writeln($validate->messages); 48 | 49 | return $validate->isValid ? 0 : 1; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Installer/AbstractInstaller.php: -------------------------------------------------------------------------------- 1 | output = $output; 33 | } 34 | 35 | /** 36 | * @return InstallOutput 37 | */ 38 | public function getOutput(): InstallOutput 39 | { 40 | // Output is optional, if not set, use null output. 41 | if (!$this->output instanceof InstallOutput) { 42 | $this->output = new InstallOutput(); 43 | } 44 | 45 | return $this->output; 46 | } 47 | 48 | /** 49 | * @return array 50 | */ 51 | public function getEnv(): array 52 | { 53 | return $this->env; 54 | } 55 | 56 | /** 57 | * Add a variable to write to the environment. 58 | * 59 | * @param string $name 60 | * @param string $value 61 | */ 62 | public function addEnv(string $name, string $value): void 63 | { 64 | $this->env[$name] = $value; 65 | } 66 | 67 | /** 68 | * Run install. 69 | */ 70 | abstract public function install(): void; 71 | 72 | /** 73 | * Get the number of steps this installer will perform. 74 | * 75 | * @return int 76 | */ 77 | abstract public function stepCount(): int; 78 | } 79 | -------------------------------------------------------------------------------- /src/Installer/ConfigDumper.php: -------------------------------------------------------------------------------- 1 | values); 31 | } 32 | 33 | /** 34 | * @param string $section 35 | * @param string $name 36 | * @param string|array $value 37 | */ 38 | public function addSection(string $section, string $name, $value): void 39 | { 40 | if (empty($value)) { 41 | return; 42 | } 43 | if (empty($this->values[$section])) { 44 | $this->values[$section] = []; 45 | } 46 | $this->values[$section][$name] = $value; 47 | } 48 | 49 | /** 50 | * @param string $toFile Write to this file 51 | */ 52 | public function dump(string $toFile): void 53 | { 54 | if (empty($this->values)) { 55 | return; 56 | } 57 | 58 | $dump = Yaml::dump($this->values); 59 | 60 | $filesystem = new Filesystem(); 61 | $filesystem->dumpFile($toFile, $dump); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Installer/Database/AbstractDatabase.php: -------------------------------------------------------------------------------- 1 | resolveDatabaseType($type); 34 | 35 | if ($name !== null) { 36 | $database->name = $name; 37 | } 38 | if ($user !== null) { 39 | $database->user = $user; 40 | } 41 | if ($pass !== null) { 42 | $database->pass = $pass; 43 | } 44 | if ($host !== null) { 45 | $database->host = $host; 46 | } 47 | if ($port !== null) { 48 | $database->port = $port; 49 | } 50 | 51 | return $database; 52 | } 53 | 54 | /** 55 | * Resolve database class. 56 | * 57 | * @param string $type Database type 58 | * 59 | * @return AbstractDatabase 60 | */ 61 | private function resolveDatabaseType(string $type): AbstractDatabase 62 | { 63 | foreach ($this->getDatabases() as $database) { 64 | if ($database->type === $type) { 65 | return $database; 66 | } 67 | } 68 | throw new \DomainException(sprintf('Unknown database type (%s). Please use mysqli, pgsql or mariadb.', $type)); 69 | } 70 | 71 | /** 72 | * @return AbstractDatabase[] 73 | */ 74 | private function getDatabases(): array 75 | { 76 | return [new MySQLDatabase(), new PostgresDatabase(), new MariaDBDatabase()]; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Installer/Database/MariaDBDatabase.php: -------------------------------------------------------------------------------- 1 | user, 28 | !empty($this->pass) ? '--password=' . $this->pass : '', 29 | '-h', 30 | $this->host, 31 | !empty($this->port) ? '--port=' . $this->port : '', 32 | '-e', 33 | sprintf('CREATE DATABASE `%s` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;', $this->name), 34 | ]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Installer/Database/PostgresDatabase.php: -------------------------------------------------------------------------------- 1 | user === 'postgres' && getenv('PGVER') && is_numeric(getenv('PGVER')) && getenv('PGVER') >= 11) { 39 | $this->user = 'travis'; 40 | if ($this->port === '') { // Only if the port is not set. 41 | if ($this->host === 'localhost') { 42 | $this->host = ''; // Use sockets, or we'll need to edit pg_hba.conf and restart the server. Only if not set. 43 | $this->port = '5433'; // We also need the port to find the correct socket file. 44 | // Travis did it again, for PostgreSQL 13, they are back to port 5432. We need that to find the socket. 45 | if ((int) getenv('PGVER') === 13) { 46 | $this->port = '5432'; // We also need the port to find the correct socket file. 47 | } 48 | } 49 | } 50 | } 51 | 52 | $passcmd = []; 53 | if (!empty($this->pass)) { 54 | $passcmd = [ 55 | 'env', 56 | 'PGPASSWORD=' . $this->pass, 57 | ]; 58 | } 59 | 60 | $hostcmd = []; 61 | if (!empty($this->host)) { 62 | $hostcmd = [ 63 | '-h', 64 | $this->host, 65 | ]; 66 | } 67 | 68 | $portcmd = []; 69 | if (!empty($this->port)) { 70 | $portcmd = [ 71 | '--port', 72 | $this->port, 73 | ]; 74 | } 75 | 76 | $cmd = array_merge( 77 | $passcmd, 78 | [ 79 | 'psql', 80 | '-c', 81 | sprintf('CREATE DATABASE "%s";', $this->name), 82 | '-U', 83 | $this->user, 84 | '-d', 85 | 'postgres', 86 | ], 87 | $hostcmd, 88 | $portcmd 89 | ); 90 | 91 | return array_filter($cmd); // Remove empties. 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Installer/EnvDumper.php: -------------------------------------------------------------------------------- 1 | $value) { 33 | $content .= sprintf('%s=%s', $name, $value) . PHP_EOL; 34 | } 35 | 36 | $filesystem = new Filesystem(); 37 | $filesystem->dumpFile($toFile, $content); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Installer/Install.php: -------------------------------------------------------------------------------- 1 | output = $output; 25 | } 26 | 27 | /** 28 | * Run the entire install process. 29 | * 30 | * @param InstallerCollection $installers 31 | */ 32 | public function runInstallation(InstallerCollection $installers): void 33 | { 34 | $this->output->start('Starting install', $installers->sumStepCount() + 1); 35 | 36 | foreach ($installers->all() as $installer) { 37 | $installer->install(); 38 | } 39 | 40 | $this->output->end('Install completed'); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Installer/InstallOutput.php: -------------------------------------------------------------------------------- 1 | progressBar = $progressBar; 34 | 35 | // Ignore logger completely when we have a progress bar. 36 | if (!$this->progressBar instanceof ProgressBar) { 37 | $this->logger = $logger; 38 | } 39 | } 40 | 41 | /** 42 | * Get the number of steps taken. 43 | * 44 | * @return int 45 | */ 46 | public function getStepCount(): int 47 | { 48 | return $this->stepCount; 49 | } 50 | 51 | /** 52 | * Starting the installation process. 53 | * 54 | * @param string $message Start message 55 | * @param int $maxSteps The number of steps that will be taken 56 | */ 57 | public function start(string $message, int $maxSteps): void 58 | { 59 | $this->info($message); 60 | 61 | if ($this->progressBar instanceof ProgressBar) { 62 | $this->progressBar->setMessage($message); 63 | $this->progressBar->start($maxSteps); 64 | } 65 | } 66 | 67 | /** 68 | * Signify the move to the next step in the installation. 69 | * 70 | * @param string $message Very short message about the step 71 | */ 72 | public function step(string $message): void 73 | { 74 | ++$this->stepCount; 75 | 76 | $this->info($message); 77 | 78 | if ($this->progressBar instanceof ProgressBar) { 79 | $this->progressBar->setMessage($message); 80 | $this->progressBar->advance(); 81 | } 82 | } 83 | 84 | /** 85 | * Ending the installation process. 86 | * 87 | * @param string $message End message 88 | */ 89 | public function end(string $message): void 90 | { 91 | $this->info($message); 92 | 93 | if ($this->progressBar instanceof ProgressBar) { 94 | $this->progressBar->setMessage($message); 95 | $this->progressBar->finish(); 96 | } 97 | } 98 | 99 | /** 100 | * Log a message, shown in lower verbosity mode. 101 | * 102 | * @param string $message 103 | * @param array $context 104 | */ 105 | public function info(string $message, array $context = []): void 106 | { 107 | if ($this->logger instanceof LoggerInterface) { 108 | $this->logger->info($message, $context); 109 | } 110 | } 111 | 112 | /** 113 | * Log a message, shown in the highest verbosity mode. 114 | * 115 | * @param string $message 116 | * @param array $context 117 | */ 118 | public function debug(string $message, array $context = []): void 119 | { 120 | if ($this->logger instanceof LoggerInterface) { 121 | $this->logger->debug($message, $context); 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Installer/InstallerCollection.php: -------------------------------------------------------------------------------- 1 | output = $output; 29 | } 30 | 31 | /** 32 | * Add an installer. 33 | * 34 | * @param AbstractInstaller $installer 35 | */ 36 | public function add(AbstractInstaller $installer): void 37 | { 38 | $installer->setOutput($this->output); 39 | $this->installers[] = $installer; 40 | } 41 | 42 | /** 43 | * @return AbstractInstaller[] 44 | */ 45 | public function all(): array 46 | { 47 | return $this->installers; 48 | } 49 | 50 | /** 51 | * Merge the environment variables from all installers. 52 | * 53 | * @return array 54 | */ 55 | public function mergeEnv(): array 56 | { 57 | $env = []; 58 | foreach ($this->installers as $installer) { 59 | $env = array_merge($env, $installer->getEnv()); 60 | } 61 | 62 | return $env; 63 | } 64 | 65 | /** 66 | * Get the total number of steps from all installers. 67 | * 68 | * @return int 69 | */ 70 | public function sumStepCount(): int 71 | { 72 | $sum = 0; 73 | foreach ($this->installers as $installer) { 74 | $sum += $installer->stepCount(); 75 | } 76 | 77 | return $sum; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Installer/InstallerFactory.php: -------------------------------------------------------------------------------- 1 | add(new MoodleInstaller($this->execute, $this->database, $this->moodle, new MoodleConfig(), $this->repo, $this->branch, $this->dataDir)); 47 | 48 | if (getenv('MOODLE_APP')) { 49 | $this->pluginsDir = $this->pluginsDir ?? 'moodle-plugin-ci-plugins'; 50 | 51 | $installers->add(new MoodleAppInstaller($this->execute, $this->pluginsDir)); 52 | } 53 | 54 | $installers->add(new PluginInstaller($this->moodle, $this->plugin, $this->pluginsDir, $this->dumper)); 55 | $installers->add(new VendorInstaller($this->moodle, $this->plugin, $this->execute, $this->noPluginNode, $this->nodeVer)); 56 | 57 | if ($this->noInit) { 58 | return; 59 | } 60 | if ($this->plugin->hasBehatFeatures() || $this->plugin->hasUnitTests()) { 61 | $installers->add(new TestSuiteInstaller($this->moodle, $this->plugin, $this->execute)); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Installer/MoodleAppInstaller.php: -------------------------------------------------------------------------------- 1 | execute = $execute; 31 | $this->pluginsDir = $pluginsDir; 32 | } 33 | 34 | public function install(): void 35 | { 36 | $this->addEnv('MOODLE_APP', 'true'); 37 | 38 | // Launch docker image. 39 | $this->getOutput()->step('Launch Moodle App docker image'); 40 | 41 | $image = getenv('MOODLE_APP_DOCKER_IMAGE') ?: 'moodlehq/moodleapp:latest-test'; 42 | $port = getenv('MOODLE_APP_PORT') ?: '443'; 43 | 44 | $this->execute->mustRun([ 45 | 'docker', 46 | 'run', 47 | '-d', 48 | '--rm', 49 | '--name=moodleapp', 50 | '-p', 51 | "8100:$port", 52 | $image, 53 | ]); 54 | 55 | // Clone plugin. 56 | $this->getOutput()->step('Clone Moodle App Behat plugin'); 57 | 58 | $pluginProject = getenv('MOODLE_APP_BEHAT_PLUGIN_PROJECT') ?: 'moodlehq/moodle-local_moodleappbehat'; 59 | $pluginRepository = getenv('MOODLE_APP_BEHAT_PLUGIN_REPOSITORY') ?: sprintf('https://github.com/%s.git', $pluginProject); 60 | $pluginBranch = getenv('MOODLE_APP_BEHAT_PLUGIN_BRANCH') ?: 'latest'; 61 | $filesystem = new Filesystem(); 62 | $validate = new Validate(); 63 | $command = [ 64 | 'git', 65 | 'clone', 66 | '--depth', 67 | '1', 68 | '--branch', 69 | $pluginBranch, 70 | $pluginRepository, 71 | ]; 72 | 73 | $filesystem->mkdir($this->pluginsDir); 74 | $storageDir = realpath($validate->directory($this->pluginsDir)); 75 | $this->execute->mustRun(new Process($command, $storageDir, null, null, null)); 76 | } 77 | 78 | public function stepCount(): int 79 | { 80 | return 2; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Installer/MoodleInstaller.php: -------------------------------------------------------------------------------- 1 | execute = $execute; 49 | $this->database = $database; 50 | $this->moodle = $moodle; 51 | $this->config = $config; 52 | $this->repo = $repo; 53 | $this->branch = $branch; 54 | $this->dataDir = $dataDir; 55 | } 56 | 57 | public function install(): void 58 | { 59 | $this->getOutput()->step('Cloning Moodle'); 60 | 61 | $cmd = [ 62 | 'git', 'clone', 63 | '--depth=1', 64 | '--branch', $this->branch, 65 | $this->repo, 66 | $this->moodle->directory, 67 | ]; 68 | 69 | $this->execute->mustRun(new Process($cmd, null, null, null, null)); 70 | 71 | // Expand the path to Moodle so all other installers use absolute path. 72 | $this->moodle->directory = $this->expandPath($this->moodle->directory); 73 | 74 | // If there are submodules, we clean up empty directories, since we 75 | // don't initialise them properly anyway. 76 | if (is_file($this->moodle->directory . '/.gitmodules')) { 77 | $process = Process::fromShellCommandline(sprintf('git config -f %s --get-regexp \'^submodule\..*\.path$\' ' . 78 | '| awk \'{ print $2 }\' | xargs -i rmdir "%s/{}"', 79 | $this->moodle->directory . '/.gitmodules', $this->moodle->directory)); 80 | $process->setTimeout(null); 81 | $this->execute->mustRun($process); 82 | } 83 | 84 | $this->getOutput()->step('Moodle assets'); 85 | 86 | $this->getOutput()->debug('Creating Moodle data directories'); 87 | 88 | $dirs = [$this->dataDir, $this->dataDir . '/phpu_moodledata', $this->dataDir . '/behat_moodledata', $this->dataDir . '/behat_dump']; 89 | 90 | $filesystem = new Filesystem(); 91 | $filesystem->mkdir($dirs); 92 | $filesystem->chmod($dirs, 0777); 93 | 94 | $this->getOutput()->debug('Create Moodle database'); 95 | $this->execute->mustRun($this->database->getCreateDatabaseCommand()); 96 | 97 | $this->getOutput()->debug('Creating Moodle\'s config file'); 98 | $contents = $this->config->createContents($this->database, $this->expandPath($this->dataDir)); 99 | $this->config->dump($this->moodle->directory . '/config.php', $contents); 100 | 101 | $this->addEnv('MOODLE_DIR', $this->moodle->directory); 102 | } 103 | 104 | /** 105 | * Converts a path to an absolute path. 106 | * 107 | * @param string $path 108 | * 109 | * @return string 110 | */ 111 | public function expandPath(string $path): string 112 | { 113 | $validate = new Validate(); 114 | 115 | return realpath($validate->directory($path)); 116 | } 117 | 118 | public function stepCount(): int 119 | { 120 | return 2; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Installer/PluginInstaller.php: -------------------------------------------------------------------------------- 1 | moodle = $moodle; 41 | $this->plugin = $plugin; 42 | $this->extraPluginsDir = $extraPluginsDir; 43 | $this->configDumper = $configDumper; 44 | } 45 | 46 | public function install(): void 47 | { 48 | $this->getOutput()->step('Install plugins'); 49 | 50 | $plugins = $this->scanForPlugins(); 51 | $plugins->add($this->plugin); 52 | $sorted = $plugins->sortByDependencies(); 53 | 54 | foreach ($sorted->all() as $plugin) { 55 | $directory = $this->installPluginIntoMoodle($plugin); 56 | 57 | if ($plugin->getComponent() === $this->plugin->getComponent()) { 58 | $this->addEnv('PLUGIN_DIR', $directory); 59 | $this->createConfigFile($directory . '/.moodle-plugin-ci.yml'); 60 | 61 | // Update plugin so other installers use the installed path. 62 | $this->plugin->directory = $directory; 63 | } 64 | } 65 | } 66 | 67 | /** 68 | * @return MoodlePluginCollection 69 | */ 70 | public function scanForPlugins(): MoodlePluginCollection 71 | { 72 | $plugins = new MoodlePluginCollection(); 73 | 74 | if (empty($this->extraPluginsDir)) { 75 | return $plugins; 76 | } 77 | 78 | /** @var SplFileInfo[] $files */ 79 | $files = Finder::create()->directories()->in($this->extraPluginsDir)->depth(0); 80 | foreach ($files as $file) { 81 | $plugins->add(new MoodlePlugin($file->getRealPath())); 82 | } 83 | 84 | return $plugins; 85 | } 86 | 87 | /** 88 | * Install the plugin into Moodle. 89 | * 90 | * @param MoodlePlugin $plugin 91 | * 92 | * @return string 93 | */ 94 | public function installPluginIntoMoodle(MoodlePlugin $plugin): string 95 | { 96 | $this->getOutput()->info(sprintf('Installing %s', $plugin->getComponent())); 97 | 98 | $directory = $this->moodle->getComponentInstallDirectory($plugin->getComponent()); 99 | 100 | if (is_dir($directory)) { 101 | throw new \RuntimeException('Plugin is already installed in standard Moodle'); 102 | } 103 | 104 | $this->getOutput()->info(sprintf('Copying plugin from %s to %s', $plugin->directory, $directory)); 105 | 106 | // Install the plugin. 107 | $filesystem = new Filesystem(); 108 | $filesystem->mirror($plugin->directory, $directory); 109 | 110 | return $directory; 111 | } 112 | 113 | /** 114 | * Create plugin config file. 115 | * 116 | * @param string $toFile 117 | */ 118 | public function createConfigFile(string $toFile): void 119 | { 120 | if (file_exists($toFile)) { 121 | $this->getOutput()->debug('Config file already exists in plugin, skipping creation of config file.'); 122 | 123 | return; 124 | } 125 | if (!$this->configDumper->hasConfig()) { 126 | $this->getOutput()->debug('No config to write out, skipping creation of config file.'); 127 | 128 | return; 129 | } 130 | $this->configDumper->dump($toFile); 131 | $this->getOutput()->debug('Created config file at ' . $toFile); 132 | } 133 | 134 | public function stepCount(): int 135 | { 136 | return 1; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Installer/VendorInstaller.php: -------------------------------------------------------------------------------- 1 | moodle = $moodle; 44 | $this->plugin = $plugin; 45 | $this->execute = $execute; 46 | $this->nodeVer = $nodeVer; 47 | $this->noPluginNode = $noPluginNode; 48 | } 49 | 50 | public function install(): void 51 | { 52 | if ($this->canInstallNode()) { 53 | $this->getOutput()->step('Installing Node.js'); 54 | $this->installNode(); 55 | } 56 | 57 | $this->getOutput()->step('Install global dependencies'); 58 | 59 | $processes = []; 60 | if ($this->plugin->hasUnitTests() || $this->plugin->hasBehatFeatures()) { 61 | $processes[] = Process::fromShellCommandline('composer install --no-interaction --prefer-dist', 62 | $this->moodle->directory, null, null, null); 63 | } 64 | $processes[] = Process::fromShellCommandline('npm install --no-progress grunt', null, null, null, null); 65 | 66 | $this->execute->mustRunAll($processes); 67 | 68 | $this->getOutput()->step('Install Moodle npm dependencies'); 69 | 70 | $this->execute->mustRun( 71 | Process::fromShellCommandline('npm install --no-progress', $this->moodle->directory, null, null, null) 72 | ); 73 | if (!$this->noPluginNode && $this->plugin->hasNodeDependencies()) { 74 | $this->getOutput()->step('Install plugin npm dependencies'); 75 | $this->execute->mustRun( 76 | Process::fromShellCommandline('npm install --no-progress', $this->plugin->directory, null, null, null) 77 | ); 78 | } 79 | 80 | $this->execute->mustRun( 81 | Process::fromShellCommandline('npx grunt ignorefiles', $this->moodle->directory, null, null, null) 82 | ); 83 | } 84 | 85 | public function stepCount(): int 86 | { 87 | return 2 + // Normally 2 steps: global dependencies and Moodle npm dependencies. 88 | ($this->canInstallNode() ? 1 : 0) + // Plus Node.js installation. 89 | ((!$this->noPluginNode && $this->plugin->hasNodeDependencies()) ? 1 : 0); // Plus plugin npm dependencies step. 90 | } 91 | 92 | /** 93 | * Check if we have nvm to proceed with Node.js installation step. 94 | * 95 | * @return bool 96 | */ 97 | public function canInstallNode(): bool 98 | { 99 | return !empty(getenv('NVM_DIR')); 100 | } 101 | 102 | /** 103 | * Install Node.js. 104 | * 105 | * In order to figure out which version to install, first look for user 106 | * specified version (NODE_VERSION env variable or --node-version param passed 107 | * for install step). If there is none, use version from .nvmrc in Moodle 108 | * directory. If file does not exist, use legacy version (lts/carbon). 109 | */ 110 | public function installNode(): void 111 | { 112 | if (!empty($this->nodeVer)) { 113 | // Use Node version specified by user. 114 | $reqversion = $this->nodeVer; 115 | file_put_contents($this->moodle->directory . '/.nvmrc', $reqversion); 116 | } elseif (!is_file($this->moodle->directory . '/.nvmrc')) { 117 | // Use legacy version. Since Moodle 3.5, all branches have the .nvmrc file. 118 | $reqversion = $this->legacyNodeVersion; 119 | file_put_contents($this->moodle->directory . '/.nvmrc', $reqversion); 120 | } 121 | 122 | $nvmDir = getenv('NVM_DIR'); 123 | $cmd = ". $nvmDir/nvm.sh; nvm install && nvm use && echo \"NVM_BIN=\$NVM_BIN\""; 124 | 125 | $process = $this->execute->passThroughProcess( 126 | Process::fromShellCommandline($cmd, $this->moodle->directory, null, null, null) 127 | ); 128 | if (!$process->isSuccessful()) { 129 | throw new \RuntimeException('Node.js installation failed.'); 130 | } 131 | // Retrieve NVM_BIN from initialisation output, we will use it to 132 | // substitute right Node.js environment in all future process runs. 133 | // @see Execute::setNodeEnv() 134 | preg_match('/^NVM_BIN=(.+)$/m', trim($process->getOutput()), $matches); 135 | if (isset($matches[1]) && is_dir($matches[1])) { 136 | $this->addEnv('RUNTIME_NVM_BIN', $matches[1]); 137 | putenv('RUNTIME_NVM_BIN=' . $matches[1]); 138 | } else { 139 | $this->getOutput()->debug('Can\'t retrieve NVM_BIN content from the command output.'); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Model/GruntTaskModel.php: -------------------------------------------------------------------------------- 1 | taskName = $taskName; 40 | $this->workingDirectory = $workingDirectory; 41 | $this->buildDirectory = $buildDirectory; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Parser/CodeParser.php: -------------------------------------------------------------------------------- 1 | create(ParserFactory::PREFER_PHP7); 56 | 57 | try { 58 | $statements = $parser->parse($this->loadFile($path)); 59 | } catch (Error $e) { 60 | throw new \RuntimeException(sprintf('Failed to parse %s file due to parse error: %s', $path, $e->getMessage()), 0, $e); 61 | } 62 | if ($statements === null) { 63 | throw new \RuntimeException(sprintf('Failed to parse %s', $path)); 64 | } 65 | 66 | return $statements; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Parser/StatementFilter.php: -------------------------------------------------------------------------------- 1 | filterClasses($statements) as $class) { 71 | if (!isset($class->name)) { 72 | continue; 73 | } 74 | $names[] = (string) $class->name; 75 | } 76 | 77 | foreach ($this->filterNamespaces($statements) as $namespace) { 78 | foreach ($this->filterClasses($namespace->stmts) as $class) { 79 | if (!isset($class->name)) { 80 | continue; 81 | } 82 | $names[] = ($namespace->name ?? '') . '\\' . $class->name; 83 | } 84 | } 85 | 86 | return $names; 87 | } 88 | 89 | /** 90 | * @param array $statements 91 | * 92 | * @return Namespace_[] 93 | */ 94 | public function filterNamespaces(array $statements): array 95 | { 96 | return array_filter($statements, function ($statement) { 97 | return $statement instanceof Namespace_; 98 | }); 99 | } 100 | 101 | /** 102 | * Extract all the assignment expressions from the statements. 103 | * 104 | * @param Stmt[] $statements 105 | * 106 | * @return Assign[] 107 | */ 108 | public function filterAssignments(array $statements): array 109 | { 110 | $assigns = []; 111 | foreach ($statements as $statement) { 112 | // Only expressions that are assigns. 113 | if ($statement instanceof Expression && $statement->expr instanceof Assign) { 114 | $assigns[] = $statement->expr; 115 | } 116 | } 117 | 118 | return $assigns; 119 | } 120 | 121 | /** 122 | * Extract all the function call expressions from the statements. 123 | * 124 | * @param Stmt[] $statements 125 | * 126 | * @return FuncCall[] 127 | */ 128 | public function filterFunctionCalls(array $statements): array 129 | { 130 | $calls = []; 131 | foreach ($statements as $statement) { 132 | // Only expressions that are function calls. 133 | if ($statement instanceof Expression && $statement->expr instanceof FuncCall) { 134 | $calls[] = $statement->expr; 135 | } 136 | } 137 | 138 | return $calls; 139 | } 140 | 141 | /** 142 | * Find first variable assignment with a given name. 143 | * 144 | * @param array $statements 145 | * @param string $name 146 | * @param string|null $notFoundError 147 | * 148 | * @return Assign 149 | */ 150 | public function findFirstVariableAssignment(array $statements, string $name, ?string $notFoundError = null): Assign 151 | { 152 | foreach ($this->filterAssignments($statements) as $assign) { 153 | if ($assign->var instanceof Variable && is_string($assign->var->name)) { 154 | if ($assign->var->name === $name) { 155 | return $assign; 156 | } 157 | } 158 | } 159 | 160 | throw new \RuntimeException($notFoundError ?: sprintf('Variable assignment $%s not found', $name)); 161 | } 162 | 163 | /** 164 | * Find first property fetch assignment with a given name. 165 | * 166 | * EG: Find $foo->bar = something. 167 | * 168 | * @param array $statements PHP statements 169 | * @param string $variable The variable name, EG: foo in $foo->bar 170 | * @param string $property The property name, EG: bar in $foo->bar 171 | * @param string|null $notFoundError Use this error when not found 172 | * 173 | * @return Assign 174 | */ 175 | public function findFirstPropertyFetchAssignment(array $statements, string $variable, string $property, ?string $notFoundError = null): Assign 176 | { 177 | foreach ($this->filterAssignments($statements) as $assign) { 178 | if ($assign->var instanceof PropertyFetch && $assign->var->name instanceof Identifier) { 179 | $propName = $assign->var->name->name; 180 | $var = $assign->var->var; 181 | if ($var instanceof Variable && is_string($var->name)) { 182 | if ($var->name === $variable && $propName === $property) { 183 | return $assign; 184 | } 185 | } 186 | } 187 | } 188 | 189 | throw new \RuntimeException($notFoundError ?: sprintf('Variable assignment $%s->%s not found', $variable, $property)); 190 | } 191 | 192 | /** 193 | * Given an array, find all the string keys. 194 | * 195 | * @param Array_ $array 196 | * 197 | * @return array 198 | */ 199 | public function arrayStringKeys(Array_ $array): array 200 | { 201 | $keys = []; 202 | foreach ($array->items as $item) { 203 | if (isset($item->key) && $item->key instanceof String_) { 204 | $keys[] = $item->key->value; 205 | } 206 | } 207 | 208 | return $keys; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/AbstractParserFinder.php: -------------------------------------------------------------------------------- 1 | parser = $parser ?: new CodeParser(); 38 | $this->filter = $filter ?: new StatementFilter(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/BehatTagFinder.php: -------------------------------------------------------------------------------- 1 | compare($match); 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/CapabilityFinder.php: -------------------------------------------------------------------------------- 1 | file); 30 | $statements = $this->parser->parseFile($file); 31 | $assign = $this->filter->findFirstVariableAssignment($statements, 'capabilities', $notFound); 32 | 33 | if (!$assign->expr instanceof Array_) { 34 | throw new \RuntimeException(sprintf('The $capabilities variable is not set to an array in %s file', $fileTokens->file)); 35 | } 36 | foreach ($this->filter->arrayStringKeys($assign->expr) as $key) { 37 | $fileTokens->compare($key); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/ClassFinder.php: -------------------------------------------------------------------------------- 1 | parser->parseFile($file); 28 | 29 | foreach ($this->filter->filterClassNames($statements) as $className) { 30 | $fileTokens->compare($className); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/FileTokens.php: -------------------------------------------------------------------------------- 1 | file = $file; 45 | } 46 | 47 | /** 48 | * Factory method for quality of life. 49 | * 50 | * @param string $file 51 | * 52 | * @return self 53 | */ 54 | public static function create(string $file): self 55 | { 56 | return new self($file); 57 | } 58 | 59 | /** 60 | * Do we have any defined tokens? 61 | * 62 | * @return bool 63 | */ 64 | public function hasTokens(): bool 65 | { 66 | return !empty($this->tokens); 67 | } 68 | 69 | /** 70 | * Do we have any hint? 71 | * 72 | * @return bool 73 | */ 74 | public function hasHint(): bool 75 | { 76 | return !empty($this->hint); 77 | } 78 | 79 | /** 80 | * @param Token $token 81 | * 82 | * @return self 83 | */ 84 | public function addToken(Token $token): self 85 | { 86 | $this->tokens[] = $token; 87 | 88 | return $this; 89 | } 90 | 91 | /** 92 | * Require that the file has this single token. 93 | * 94 | * @param string $token 95 | * 96 | * @return self 97 | */ 98 | public function mustHave(string $token): self 99 | { 100 | return $this->addToken(new Token($token)); 101 | } 102 | 103 | /** 104 | * Require that the file has all of these tokens. 105 | * 106 | * @param array $tokens 107 | * 108 | * @return self 109 | */ 110 | public function mustHaveAll(array $tokens): self 111 | { 112 | foreach ($tokens as $token) { 113 | $this->mustHave($token); 114 | } 115 | 116 | return $this; 117 | } 118 | 119 | /** 120 | * Require that the file has any of the passed tokens. 121 | * 122 | * @param array $tokens 123 | * 124 | * @return self 125 | */ 126 | public function mustHaveAny(array $tokens): self 127 | { 128 | return $this->addToken(new Token($tokens)); 129 | } 130 | 131 | /** 132 | * Given some string, see if it matches any of our tokens. 133 | * 134 | * @param string $string 135 | */ 136 | public function compare(string $string): void 137 | { 138 | foreach ($this->tokens as $token) { 139 | if ($token->hasTokenBeenFound()) { 140 | continue; 141 | } 142 | $token->compare($string); 143 | } 144 | } 145 | 146 | /** 147 | * See if the beginning of the passed string matches any of our token(s). 148 | * 149 | * @param string $string 150 | */ 151 | public function compareStart(string $string): void 152 | { 153 | foreach ($this->tokens as $token) { 154 | if ($token->hasTokenBeenFound()) { 155 | continue; 156 | } 157 | $token->compareStart($string); 158 | } 159 | } 160 | 161 | /** 162 | * Have all the tokens been found yet? 163 | * 164 | * @return bool 165 | */ 166 | public function hasFoundAllTokens(): bool 167 | { 168 | foreach ($this->tokens as $token) { 169 | if (!$token->hasTokenBeenFound()) { 170 | return false; 171 | } 172 | } 173 | 174 | return true; 175 | } 176 | 177 | /** 178 | * Reset found state on all tokens. 179 | */ 180 | public function resetTokens(): void 181 | { 182 | foreach ($this->tokens as $token) { 183 | $token->reset(); 184 | } 185 | } 186 | 187 | /** 188 | * Not found error additional information guiding user how to fix it (optional). 189 | * 190 | * @param string $hint 191 | * 192 | * @return self 193 | */ 194 | public function notFoundHint(string $hint): self 195 | { 196 | $this->hint = $hint; 197 | 198 | return $this; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/FinderInterface.php: -------------------------------------------------------------------------------- 1 | 10 | * License http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 11 | */ 12 | 13 | namespace MoodlePluginCI\PluginValidate\Finder; 14 | 15 | use PhpParser\Node\Name; 16 | 17 | /** 18 | * Finds function call. 19 | */ 20 | class FunctionCallFinder extends AbstractParserFinder 21 | { 22 | public function getType(): string 23 | { 24 | return 'function call'; 25 | } 26 | 27 | public function findTokens($file, FileTokens $fileTokens): void 28 | { 29 | $statements = $this->parser->parseFile($file); 30 | 31 | foreach ($this->filter->filterFunctionCalls($statements) as $funccall) { 32 | if ($funccall->name instanceof Name) { 33 | $fileTokens->compare((string) $funccall->name); 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/FunctionFinder.php: -------------------------------------------------------------------------------- 1 | parser->parseFile($file); 28 | 29 | foreach ($this->filter->filterFunctions($statements) as $function) { 30 | $fileTokens->compare((string) $function->name); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/LangFinder.php: -------------------------------------------------------------------------------- 1 | parser->parseFile($file); 32 | 33 | foreach ($this->filter->filterAssignments($statements) as $assign) { 34 | // Looking for an assignment to an array key, EG: $string['something']. 35 | if ($assign->var instanceof ArrayDimFetch) { 36 | // Verify that the array name is $string. 37 | $arrayName = $assign->var->var; 38 | if (!$arrayName instanceof Variable || $arrayName->name !== 'string') { 39 | continue; 40 | } 41 | // Grab the array index. 42 | $arrayIndex = $assign->var->dim; 43 | if ($arrayIndex instanceof String_) { 44 | $fileTokens->compare($arrayIndex->value); 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/TableFinder.php: -------------------------------------------------------------------------------- 1 | findTables($file) as $table) { 28 | $fileTokens->compare($table); 29 | } 30 | } 31 | 32 | /** 33 | * @param string $file 34 | * 35 | * @return array 36 | */ 37 | protected function findTables(string $file): array 38 | { 39 | $tables = []; 40 | $xml = simplexml_load_file($file); 41 | $elements = $xml->xpath('TABLES/TABLE') ?? false ?: []; 42 | foreach ($elements as $element) { 43 | if (isset($element['NAME'])) { 44 | $tables[] = (string) $element['NAME']; 45 | } 46 | } 47 | 48 | return $tables; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/TablePrefixFinder.php: -------------------------------------------------------------------------------- 1 | findTables($file); 28 | $total = count($tables); 29 | for ($i = 0; $i < $total; ++$i) { 30 | $fileTokens->compareStart($tables[$i]); 31 | 32 | // This runs after every table except for the last one. 33 | if ($i !== $total - 1) { 34 | if (!$fileTokens->hasFoundAllTokens()) { 35 | break; // Found an invalid table name, can stop. 36 | } 37 | // Current table name valid, reset tokens, so we can see if the next table is valid or not. 38 | $fileTokens->resetTokens(); 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/PluginValidate/Finder/Token.php: -------------------------------------------------------------------------------- 1 | tokens = $token; 40 | } 41 | 42 | /** 43 | * Reset token found state. 44 | */ 45 | public function reset(): void 46 | { 47 | $this->found = false; 48 | } 49 | 50 | /** 51 | * @return bool 52 | */ 53 | public function hasTokenBeenFound(): bool 54 | { 55 | return $this->found; 56 | } 57 | 58 | /** 59 | * See if the passed string matches our token(s). 60 | * 61 | * @param string $string 62 | */ 63 | public function compare(string $string): void 64 | { 65 | foreach ($this->tokens as $token) { 66 | if (strcasecmp($token, $string) === 0) { 67 | $this->found = true; 68 | } 69 | } 70 | } 71 | 72 | /** 73 | * See if the beginning of the passed string matches our token(s). 74 | * 75 | * @param string $string 76 | */ 77 | public function compareStart(string $string): void 78 | { 79 | $lowerString = strtolower($string); 80 | foreach ($this->tokens as $token) { 81 | if (strpos($lowerString, strtolower($token)) === 0) { 82 | $this->found = true; 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/PluginValidate/Plugin.php: -------------------------------------------------------------------------------- 1 | component = $component; 49 | $this->type = $type; 50 | $this->name = $name; 51 | $this->directory = $directory; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/PluginValidate/PluginValidate.php: -------------------------------------------------------------------------------- 1 | plugin = $plugin; 55 | $this->requirements = $requirements; 56 | } 57 | 58 | /** 59 | * Add an error to the validation. 60 | * 61 | * @param string $message 62 | */ 63 | public function addError(string $message): void 64 | { 65 | $this->messages[] = sprintf('X %s', $message); 66 | $this->isValid = false; 67 | } 68 | 69 | /** 70 | * Add a success to the validation. 71 | * 72 | * @param string $message 73 | */ 74 | public function addSuccess(string $message): void 75 | { 76 | $this->messages[] = sprintf('> %s', $message); 77 | } 78 | 79 | /** 80 | * Add a warning to the validation. 81 | * 82 | * @param string $message 83 | */ 84 | public function addWarning(string $message): void 85 | { 86 | $this->messages[] = sprintf('! %s', $message); 87 | } 88 | 89 | /** 90 | * Add messages about finding or not finding tokens in a file. 91 | * 92 | * @param string $type 93 | * @param FileTokens $fileTokens 94 | */ 95 | public function addMessagesFromTokens(string $type, FileTokens $fileTokens): void 96 | { 97 | foreach ($fileTokens->tokens as $token) { 98 | if ($token->hasTokenBeenFound()) { 99 | $this->addSuccess(sprintf('In %s, found %s %s', $fileTokens->file, $type, implode(' OR ', $token->tokens))); 100 | } else { 101 | $this->addError(sprintf('In %s, failed to find %s %s', $fileTokens->file, $type, implode(' OR ', $token->tokens))); 102 | if ($fileTokens->hasHint()) { 103 | $this->addError(sprintf('Hint: %s', $fileTokens->hint)); 104 | } 105 | } 106 | } 107 | } 108 | 109 | /** 110 | * Run verification of a plugin. 111 | */ 112 | public function verifyRequirements(): void 113 | { 114 | $this->findRequiredFiles($this->requirements->getRequiredFiles()); 115 | $this->findRequiredTokens(new FunctionFinder(), $this->requirements->getRequiredFunctions()); 116 | $this->findRequiredTokens(new ClassFinder(), $this->requirements->getRequiredClasses()); 117 | $this->findRequiredTokens(new LangFinder(), [$this->requirements->getRequiredStrings()]); 118 | $this->findRequiredTokens(new CapabilityFinder(), [$this->requirements->getRequiredCapabilities()]); 119 | $this->findRequiredTokens(new TableFinder(), [$this->requirements->getRequiredTables()]); 120 | $this->findRequiredTokens(new TablePrefixFinder(), [$this->requirements->getRequiredTablePrefix()]); 121 | $this->findRequiredTokens(new BehatTagFinder(), $this->requirements->getRequiredBehatTags()); 122 | $this->findRequiredTokens(new FunctionCallFinder(), $this->requirements->getRequiredFunctionCalls()); 123 | } 124 | 125 | /** 126 | * Ensure a list of files exists. 127 | * 128 | * @param array $files 129 | */ 130 | public function findRequiredFiles(array $files): void 131 | { 132 | foreach ($files as $file) { 133 | if (file_exists($this->plugin->directory . '/' . $file)) { 134 | $this->addSuccess(sprintf('Found required file: %s', $file)); 135 | } else { 136 | $this->addError(sprintf('Failed to find required file: %s', $file)); 137 | } 138 | } 139 | } 140 | 141 | /** 142 | * Find required tokens in a file. 143 | * 144 | * @param FinderInterface $finder 145 | * @param FileTokens[] $tokenCollection 146 | */ 147 | public function findRequiredTokens(FinderInterface $finder, array $tokenCollection): void 148 | { 149 | foreach ($tokenCollection as $fileTokens) { 150 | if (!$fileTokens->hasTokens()) { 151 | continue; 152 | } 153 | $file = $this->plugin->directory . '/' . $fileTokens->file; 154 | 155 | if (!file_exists($file)) { 156 | $this->addWarning(sprintf('Skipping validation of missing or optional file: %s', $fileTokens->file)); 157 | continue; 158 | } 159 | 160 | try { 161 | $finder->findTokens($file, $fileTokens); 162 | $this->addMessagesFromTokens($finder->getType(), $fileTokens); 163 | } catch (\Exception $e) { 164 | $this->addError($e->getMessage()); 165 | } 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/AbstractRequirements.php: -------------------------------------------------------------------------------- 1 | plugin = $plugin; 41 | $this->moodleVersion = $moodleVersion; 42 | } 43 | 44 | /** 45 | * Factory method for generating FileTokens instances for all feature files in a plugin. 46 | * 47 | * @param array $tags 48 | * 49 | * @return FileTokens[] 50 | */ 51 | protected function behatTagsFactory(array $tags): array 52 | { 53 | $fileTokens = []; 54 | 55 | try { 56 | $files = Finder::create()->files()->in($this->plugin->directory)->path('tests/behat')->name('*.feature')->getIterator(); 57 | foreach ($files as $file) { 58 | $fileTokens[] = FileTokens::create($file->getRelativePathname())->mustHaveAll($tags); 59 | } 60 | } catch (\Exception $e) { 61 | // Nothing to do. 62 | } 63 | 64 | return $fileTokens; 65 | } 66 | 67 | /** 68 | * Helper method to check file existence. 69 | * 70 | * @param string $file 71 | * 72 | * @return bool 73 | */ 74 | protected function fileExists(string $file): bool 75 | { 76 | return file_exists($this->plugin->directory . '/' . $file); 77 | } 78 | 79 | /** 80 | * Required function calls. 81 | * 82 | * @return FileTokens[] 83 | */ 84 | abstract public function getRequiredFunctionCalls(): array; 85 | 86 | /** 87 | * An array of required files, paths are relative to the plugin directory. 88 | * 89 | * @return array 90 | */ 91 | abstract public function getRequiredFiles(): array; 92 | 93 | /** 94 | * Required plugin functions. 95 | * 96 | * @return FileTokens[] 97 | */ 98 | abstract public function getRequiredFunctions(): array; 99 | 100 | /** 101 | * Required plugin classes. 102 | * 103 | * @return FileTokens[] 104 | */ 105 | abstract public function getRequiredClasses(): array; 106 | 107 | /** 108 | * Required plugin string definitions. 109 | * 110 | * @return FileTokens 111 | */ 112 | abstract public function getRequiredStrings(): FileTokens; 113 | 114 | /** 115 | * Required plugin capability definitions. 116 | * 117 | * @return FileTokens 118 | */ 119 | abstract public function getRequiredCapabilities(): FileTokens; 120 | 121 | /** 122 | * Required plugin database tables. 123 | * 124 | * @return FileTokens 125 | */ 126 | abstract public function getRequiredTables(): FileTokens; 127 | 128 | /** 129 | * Required plugin database table prefix. 130 | * 131 | * @return FileTokens 132 | */ 133 | abstract public function getRequiredTablePrefix(): FileTokens; 134 | 135 | /** 136 | * Required Behat tags for feature files. 137 | * 138 | * @return FileTokens[] 139 | */ 140 | abstract public function getRequiredBehatTags(): array; 141 | } 142 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/AuthRequirements.php: -------------------------------------------------------------------------------- 1 | mustHave('auth_plugin_' . $this->plugin->name), 33 | ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/BlockRequirements.php: -------------------------------------------------------------------------------- 1 | plugin->component . '.php', 26 | 'db/access.php', 27 | ]); 28 | } 29 | 30 | public function getRequiredClasses(): array 31 | { 32 | return [ 33 | FileTokens::create($this->plugin->component . '.php')->mustHave($this->plugin->component), 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/DataformatRequirements.php: -------------------------------------------------------------------------------- 1 | getLangFile())->mustHave('dataformat'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/FilterRequirements.php: -------------------------------------------------------------------------------- 1 | moodleVersion >= 405) { 26 | $files[] = 'classes/text_filter.php'; 27 | } else { 28 | // This must exist in 4.5 if plugin supports older version, but we don't identify support range to validate it. 29 | $files[] = 'filter.php'; 30 | } 31 | 32 | return array_merge(parent::getRequiredFiles(), $files); 33 | } 34 | 35 | public function getRequiredClasses(): array 36 | { 37 | if ($this->moodleVersion <= 404 && !$this->fileExists('classes/text_filter.php')) { 38 | // Plugin does not support 4.5, check class presence in filter.php 39 | return [ 40 | FileTokens::create('filter.php')->mustHave('filter_' . $this->plugin->name), 41 | ]; 42 | } 43 | 44 | return [ 45 | FileTokens::create('classes/text_filter.php')->mustHave("filter_{$this->plugin->name}\\text_filter"), 46 | ]; 47 | } 48 | 49 | public function getRequiredStrings(): FileTokens 50 | { 51 | return FileTokens::create($this->getLangFile())->mustHave('filtername'); 52 | } 53 | 54 | public function getRequiredFunctionCalls(): array 55 | { 56 | if ($this->moodleVersion <= 404 && !$this->fileExists('classes/text_filter.php')) { 57 | return []; 58 | } 59 | 60 | return [ 61 | FileTokens::create('filter.php')->mustHave('class_alias')->notFoundHint('https://moodledev.io/docs/4.5/devupdate#filter-plugins'), 62 | ]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/FormatRequirements.php: -------------------------------------------------------------------------------- 1 | mustHave('format_' . $this->plugin->name . '_renderer'), 33 | ]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/GenericRequirements.php: -------------------------------------------------------------------------------- 1 | plugin->component . '.php'; 27 | } 28 | 29 | public function getRequiredFiles(): array 30 | { 31 | return [ 32 | 'version.php', 33 | $this->getLangFile(), 34 | ]; 35 | } 36 | 37 | public function getRequiredFunctions(): array 38 | { 39 | return [ 40 | FileTokens::create('db/upgrade.php')->mustHave('xmldb_' . $this->plugin->component . '_upgrade'), 41 | ]; 42 | } 43 | 44 | public function getRequiredClasses(): array 45 | { 46 | return []; 47 | } 48 | 49 | public function getRequiredFunctionCalls(): array 50 | { 51 | return []; 52 | } 53 | 54 | public function getRequiredStrings(): FileTokens 55 | { 56 | return FileTokens::create($this->getLangFile())->mustHave('pluginname'); 57 | } 58 | 59 | public function getRequiredCapabilities(): FileTokens 60 | { 61 | return FileTokens::create('db/access.php'); // None. 62 | } 63 | 64 | public function getRequiredTables(): FileTokens 65 | { 66 | return FileTokens::create('db/install.xml'); // None. 67 | } 68 | 69 | public function getRequiredTablePrefix(): FileTokens 70 | { 71 | return FileTokens::create('db/install.xml')->mustHave($this->plugin->component); 72 | } 73 | 74 | public function getRequiredBehatTags(): array 75 | { 76 | return $this->behatTagsFactory(['@' . $this->plugin->type, '@' . $this->plugin->component]); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/ModuleRequirements.php: -------------------------------------------------------------------------------- 1 | plugin->name . '.php'; 25 | } 26 | 27 | public function getRequiredFiles(): array 28 | { 29 | return array_merge(parent::getRequiredFiles(), [ 30 | 'lib.php', 31 | 'mod_form.php', 32 | 'view.php', 33 | 'index.php', 34 | 'db/install.xml', 35 | 'db/access.php', 36 | ]); 37 | } 38 | 39 | public function getRequiredFunctions(): array 40 | { 41 | return [ 42 | FileTokens::create('lib.php')->mustHave($this->plugin->name . '_add_instance')->mustHave($this->plugin->name . '_update_instance'), 43 | FileTokens::create('db/upgrade.php')->mustHave('xmldb_' . $this->plugin->name . '_upgrade'), 44 | ]; 45 | } 46 | 47 | public function getRequiredStrings(): FileTokens 48 | { 49 | return FileTokens::create($this->getLangFile()) 50 | ->mustHaveAny(['modulename', 'pluginname']) 51 | ->mustHave($this->plugin->name . ':addinstance'); 52 | } 53 | 54 | public function getRequiredCapabilities(): FileTokens 55 | { 56 | return FileTokens::create('db/access.php')->mustHave('mod/' . $this->plugin->name . ':addinstance'); 57 | } 58 | 59 | public function getRequiredTables(): FileTokens 60 | { 61 | return FileTokens::create('db/install.xml')->mustHave($this->plugin->name); 62 | } 63 | 64 | public function getRequiredTablePrefix(): FileTokens 65 | { 66 | return FileTokens::create('db/install.xml')->mustHaveAny([$this->plugin->name, $this->plugin->component]); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/QuestionRequirements.php: -------------------------------------------------------------------------------- 1 | mustHaveAny(['qtype_', 'question_']); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/RepositoryRequirements.php: -------------------------------------------------------------------------------- 1 | mustHave($this->plugin->component), 34 | ]; 35 | } 36 | 37 | public function getRequiredStrings(): FileTokens 38 | { 39 | return parent::getRequiredStrings()->mustHave($this->plugin->name . ':view'); 40 | } 41 | 42 | public function getRequiredCapabilities(): FileTokens 43 | { 44 | return FileTokens::create('db/access.php')->mustHave('repository/' . $this->plugin->name . ':view'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/RequirementsResolver.php: -------------------------------------------------------------------------------- 1 | new AuthRequirements($plugin, $moodleVersion), 34 | 'block' => new BlockRequirements($plugin, $moodleVersion), 35 | 'dataformat' => new DataformatRequirements($plugin, $moodleVersion), 36 | 'filter' => new FilterRequirements($plugin, $moodleVersion), 37 | 'format' => new FormatRequirements($plugin, $moodleVersion), 38 | 'mod' => new ModuleRequirements($plugin, $moodleVersion), 39 | 'qtype' => new QuestionRequirements($plugin, $moodleVersion), 40 | 'repository' => new RepositoryRequirements($plugin, $moodleVersion), 41 | 'theme' => new ThemeRequirements($plugin, $moodleVersion), 42 | ]; 43 | 44 | if (array_key_exists($plugin->type, $map)) { 45 | return $map[$plugin->type]; 46 | } 47 | 48 | return new GenericRequirements($plugin, $moodleVersion); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/PluginValidate/Requirements/ThemeRequirements.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public int $parallelWaitTime = 200000; 39 | 40 | /** 41 | * Constructor. 42 | * 43 | * @param OutputInterface|null $output 44 | * @param ProcessHelper|null $helper 45 | */ 46 | public function __construct(?OutputInterface $output = null, ?ProcessHelper $helper = null) 47 | { 48 | $this->setOutput($output); 49 | $this->setHelper($helper); 50 | } 51 | 52 | /** 53 | * Output setter. 54 | * 55 | * @param OutputInterface|null $output 56 | */ 57 | public function setOutput(?OutputInterface $output): void 58 | { 59 | $this->output = $output ?? new NullOutput(); 60 | } 61 | 62 | /** 63 | * Process helper setter. 64 | * 65 | * @param ProcessHelper|null $helper 66 | */ 67 | public function setHelper(?ProcessHelper $helper): void 68 | { 69 | if (empty($helper)) { 70 | $helper = new ProcessHelper(); 71 | // Looks like $helper->run is not possible without DebugFormatterHelper. 72 | $helper->setHelperSet(new HelperSet([new DebugFormatterHelper()])); 73 | } 74 | $this->helper = $helper; 75 | } 76 | 77 | /** 78 | * Sets Node.js environment for process. 79 | * 80 | * We call 'nvm use' as part of install routine, but we can't export env 81 | * variable containing path to required version npm binary to make it 82 | * available in each script run (CI step). To overcome that limitation, 83 | * we store this path in RUNTIME_NVM_BIN custom variable (that install step 84 | * dumps into .env file) and use it to substitute Node.js environment 85 | * in processes we execute. 86 | * 87 | * @param Process $process An instance of Process 88 | * 89 | * @return Process 90 | */ 91 | public function setNodeEnv(Process $process): Process 92 | { 93 | if (getenv('RUNTIME_NVM_BIN')) { 94 | // Concatenate RUNTIME_NVM_BIN with PATH, so the correct version of 95 | // npm binary is used within process. 96 | $env = ['PATH' => (getenv('RUNTIME_NVM_BIN') ?: '') . ':' . (getenv('PATH') ?: '')]; 97 | $process->setEnv($env); 98 | } 99 | 100 | return $process; 101 | } 102 | 103 | /** 104 | * @param string[]|Process $cmd An instance of Process or the command to run and its arguments listed as different entities 105 | * @param string|null $error An error message that must be displayed if something went wrong 106 | * 107 | * @return Process 108 | */ 109 | public function run($cmd, ?string $error = null): Process 110 | { 111 | if (!($cmd instanceof Process)) { 112 | $cmd = new Process($cmd); 113 | } 114 | $this->setNodeEnv($cmd); 115 | 116 | return $this->helper->run($this->output, $cmd, $error); 117 | } 118 | 119 | /** 120 | * @param string[]|Process $cmd An instance of Process or the command to run and its arguments listed as different entities 121 | * @param string|null $error An error message that must be displayed if something went wrong 122 | * 123 | * @return Process 124 | */ 125 | public function mustRun($cmd, ?string $error = null): Process 126 | { 127 | if (!($cmd instanceof Process)) { 128 | $cmd = new Process($cmd); 129 | } 130 | $this->setNodeEnv($cmd); 131 | 132 | return $this->helper->mustRun($this->output, $cmd, $error); 133 | } 134 | 135 | /** 136 | * @param Process[] $processes 137 | */ 138 | public function runAll(array $processes): void 139 | { 140 | if ($this->output->isVeryVerbose()) { 141 | // If verbose, then do not run in parallel, so we get sane debug output. 142 | array_map([$this, 'run'], $processes); 143 | 144 | return; 145 | } 146 | foreach ($processes as $process) { 147 | $this->setNodeEnv($process)->start(); 148 | usleep($this->parallelWaitTime); 149 | } 150 | foreach ($processes as $process) { 151 | $process->wait(); 152 | } 153 | } 154 | 155 | /** 156 | * @param Process[] $processes 157 | */ 158 | public function mustRunAll(array $processes): void 159 | { 160 | $this->runAll($processes); 161 | 162 | foreach ($processes as $process) { 163 | if ($process instanceof MoodleProcess) { 164 | $process->checkOutputForProblems(); 165 | } 166 | if (!$process->isSuccessful()) { 167 | throw new ProcessFailedException($process); 168 | } 169 | } 170 | } 171 | 172 | /** 173 | * Run a command and send output, unaltered, immediately. 174 | * 175 | * @param string[] $commandline The command to run and its arguments listed as different entities 176 | * @param string|null $cwd The working directory or null to use the working dir of the current PHP process 177 | * @param int|float|null $timeout The timeout in seconds or null to disable 178 | * 179 | * @return Process 180 | */ 181 | public function passThrough(array $commandline, ?string $cwd = null, ?float $timeout = null): Process 182 | { 183 | return $this->passThroughProcess(new Process($commandline, $cwd, null, null, $timeout)); 184 | } 185 | 186 | /** 187 | * Run a process and send output, unaltered, immediately. 188 | * 189 | * @param Process $process 190 | * 191 | * @return Process 192 | */ 193 | public function passThroughProcess(Process $process): Process 194 | { 195 | if ($this->output->isVeryVerbose()) { 196 | $this->output->writeln(sprintf(' RUN %s', $process->getCommandLine())); 197 | } 198 | $this->setNodeEnv($process)->run(function (string $type, string $buffer) { 199 | $this->output->write($buffer); 200 | }); 201 | 202 | return $process; 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/Process/MoodleDebugException.php: -------------------------------------------------------------------------------- 1 | getCommandLine() 27 | ); 28 | 29 | if (!$process->isOutputDisabled()) { 30 | $error .= sprintf("\n\nOutput\n======\n%s", $process->getOutput()); 31 | } 32 | 33 | parent::__construct($error); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Process/MoodlePhpException.php: -------------------------------------------------------------------------------- 1 | getCommandLine() 27 | ); 28 | 29 | if (!$process->isOutputDisabled()) { 30 | $error .= sprintf("\n\nError Output\n============\n%s", $process->getErrorOutput()); 31 | } 32 | 33 | parent::__construct($error); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Process/MoodleProcess.php: -------------------------------------------------------------------------------- 1 | find(); 34 | // By telling PHP to log errors without having a log file, PHP will write 35 | // errors to STDERR in a specific format (each line is prefixed with PHP). 36 | $cmd = array_merge( 37 | [ 38 | $phpBinary, 39 | '-d', 40 | 'log_errors=1', 41 | '-d', 42 | 'error_log=null', 43 | ], 44 | $command, 45 | ); 46 | 47 | parent::__construct($cmd, $cwd, $env, null, $timeout); 48 | } 49 | 50 | public function isSuccessful(): bool 51 | { 52 | $isSuccessful = parent::isSuccessful(); 53 | 54 | // If successful, ensure there was no error output. 55 | if ($isSuccessful) { 56 | try { 57 | $this->checkOutputForProblems(); 58 | } catch (\Exception $e) { 59 | $isSuccessful = false; 60 | } 61 | } 62 | 63 | return $isSuccessful; 64 | } 65 | 66 | public function mustRun(?callable $callback = null, array $env = []): Process 67 | { 68 | parent::mustRun($callback, $env); 69 | 70 | // Check for problems with output. 71 | $this->checkOutputForProblems(); 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * Checks to make sure that there are no problems with the output. 78 | * 79 | * Problems would include PHP errors or Moodle debugging messages. 80 | */ 81 | public function checkOutputForProblems(): void 82 | { 83 | if (!$this->isStarted()) { 84 | throw new \LogicException(sprintf('Process must be started before calling %s.', __FUNCTION__)); 85 | } 86 | if ($this->isOutputDisabled()) { 87 | throw new \LogicException('Output has been disabled, cannot verify if Moodle script ran without problems'); 88 | } 89 | if ($this->hasPhpErrorMessages($this->getErrorOutput())) { 90 | throw new MoodlePhpException($this); 91 | } 92 | if ($this->hasDebuggingMessages($this->getOutput())) { 93 | throw new MoodleDebugException($this); 94 | } 95 | } 96 | 97 | /** 98 | * Search output for Moodle debugging messages. 99 | * 100 | * @param string $output Output content to check 101 | * 102 | * @return bool 103 | */ 104 | public function hasDebuggingMessages(string $output): bool 105 | { 106 | // Looks for something like the following which is a debug message and the start of the debug trace: 107 | // ++ Some message ++ 108 | // * line 109 | return preg_match("/\\+\\+ .* \\+\\+\n\\* line/", $output) !== 0; 110 | } 111 | 112 | /** 113 | * Search output for PHP errors. 114 | * 115 | * @param string $output Output content to check 116 | * 117 | * @return bool 118 | */ 119 | public function hasPhpErrorMessages(string $output): bool 120 | { 121 | // Looks for something like the following which is a debug message and the start of the debug trace: 122 | // PHP Notice: Undefined index: bat in /path/to/file.php on line 30 123 | return preg_match('/PHP [\w\s]+:/', $output) !== 0; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/StandardResolver.php: -------------------------------------------------------------------------------- 1 | [ 34 | __DIR__ . '/../vendor/moodlehq/moodle-cs/moodle', 35 | ], 36 | ]; 37 | 38 | $this->standards = $standards + $defaultStandards; 39 | } 40 | 41 | /** 42 | * Determine if a standard is known or not. 43 | * 44 | * @param string $name The standard name 45 | * 46 | * @return bool 47 | */ 48 | public function hasStandard($name) 49 | { 50 | return array_key_exists($name, $this->standards); 51 | } 52 | 53 | /** 54 | * Find the location of a standard. 55 | * 56 | * @param string $name The standard name 57 | * 58 | * @return string 59 | */ 60 | public function resolve($name) 61 | { 62 | if (!$this->hasStandard($name)) { 63 | throw new \InvalidArgumentException('Unknown coding standard: ' . $name); 64 | } 65 | 66 | foreach ($this->standards[$name] as $location) { 67 | if (file_exists($location)) { 68 | return $location; 69 | } 70 | } 71 | 72 | throw new \RuntimeException(sprintf('Failed to find the \'%s\' coding standard, likely need to run Composer install', $name)); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Validate.php: -------------------------------------------------------------------------------- 1 | realPath($path); 45 | if (!is_dir($dir)) { 46 | throw new \InvalidArgumentException(sprintf('The path is not a directory: %s', $dir)); 47 | } 48 | 49 | return $path; 50 | } 51 | 52 | /** 53 | * Validate a file path. 54 | * 55 | * @param string $path 56 | * 57 | * @return string 58 | */ 59 | public function filePath($path) 60 | { 61 | $file = $this->realPath($path); 62 | if (!is_file($file)) { 63 | throw new \InvalidArgumentException(sprintf('The path is not a file: %s', $file)); 64 | } 65 | 66 | return $path; 67 | } 68 | 69 | /** 70 | * Validate git branch name. 71 | * 72 | * @param string $branch 73 | * 74 | * @return string 75 | */ 76 | public function gitBranch($branch) 77 | { 78 | $options = ['options' => ['regexp' => '/^[a-zA-Z0-9\/\+\._-]+$/']]; 79 | if (filter_var($branch, FILTER_VALIDATE_REGEXP, $options) === false) { 80 | throw new \InvalidArgumentException(sprintf("Invalid characters found in git branch name '%s'. Use only letters, numbers, underscore, hyphen and forward slashes.", $branch)); 81 | } 82 | 83 | return $branch; 84 | } 85 | 86 | /** 87 | * Validate git URL. 88 | * 89 | * @param string $url 90 | * 91 | * @return string 92 | */ 93 | public function gitUrl($url) 94 | { 95 | // Source/credit: https://github.com/jonschlinkert/is-git-url/blob/master/index.js 96 | $options = ['options' => ['regexp' => '/(?:git|ssh|https?|git@[\w\.]+):(?:\/\/)?[\w\.@:\/~_-]+\.git(?:\/?|\#[\d\w\.\-_]+?)$/']]; 97 | if (filter_var($url, FILTER_VALIDATE_REGEXP, $options) === false) { 98 | throw new \InvalidArgumentException(sprintf('Invalid URL: %s', $url)); 99 | } 100 | 101 | return $url; 102 | } 103 | } 104 | --------------------------------------------------------------------------------