├── 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 |
--------------------------------------------------------------------------------