diff --git a/README.md b/README.md index a419fea8..6de99878 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,45 @@ Task Runner commands can be customized in two ways: this file to declare default options which are expected to work with your application under regular circumstances. This file should be committed in the project. + * Third parties might implement config providers to modify the config. A + config provider is a class implementing the `ConfigProviderInterface`. + Such a class should be placed under the `TaskRunner\ConfigProviders` + relative namespace. For instance when `Some\Namespace` points to `src/` + directory, then the config provider class should be placed under the + `src/TaskRunner/ConfigProviders` directory and will have the namespace set + to `Some\Namespace\TaskRunner\ConfigProviders`. The class name should end + with the `ConfigProvider` suffix. Use the `::provide()` method to alter + the configuration object. A `@priority` annotation tag can be defined in + the class docblock in order to determine the order in which the config + providers are running. If omitted, `@priority 0` is assumed. This + mechanism allows also to insert custom YAML config files in the flow, see + the following example: + ``` + namespace Some\Namespace\TaskRunner\ConfigProviders; + + use OpenEuropa\TaskRunner\Contract\ConfigProviderInterface; + use OpenEuropa\TaskRunner\Traits\ConfigFromFilesTrait; + use Robo\Config\Config; + + /** + * @priority 100 + */ + class AddCustomFileConfigProvider implements ConfigProviderInterface + { + use ConfigFromFilesTrait; + public static function provide(Config $config): void + { + // Load the configuration from custom.yml and custom2.yml and + // apply it to the configuration object. This will override config + // from runner.yml.dist (which has priority 1500) but get + // overridden by the config from runner.yml (priority -1000). + static::importFromFiles($config, [ + 'custom.yml', + 'custom2.yml', + ]); + } + } + ``` * `runner.yml` - project specific user overrides. This file is also located in the root folder of the project that depends on the Task Runner. This file can be used to override options with values that are specific to the diff --git a/composer.json b/composer.json index f3f1eae9..1a6085ce 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "prefer-stable": true, "require": { "php": ">=7.2", - "consolidation/robo": "^1.4", + "consolidation/robo": "^1.4.7", "gitonomy/gitlib": "^1.0", "jakeasmith/http_build_url": "^1.0.1", "nuvoleweb/robo-config": "^0.2.1", diff --git a/src/Commands/RunnerCommands.php b/src/Commands/RunnerCommands.php new file mode 100644 index 00000000..b072fb3a --- /dev/null +++ b/src/Commands/RunnerCommands.php @@ -0,0 +1,42 @@ +getConfig()->export(); + } else { + if (!$this->getConfig()->has($key)) { + throw new AbortTasksException("Invalid '$key' key."); + } + $config = $this->getConfig()->get($key); + } + return trim(Yaml::dump($config, 10, 2)); + } +} diff --git a/src/Contract/ConfigProviderInterface.php b/src/Contract/ConfigProviderInterface.php new file mode 100644 index 00000000..86bd1989 --- /dev/null +++ b/src/Contract/ConfigProviderInterface.php @@ -0,0 +1,50 @@ +(). + * + * @param \Robo\Config\Config $config + * + * @see ConfigFromFilesTrait::importFromFiles() + */ + public static function provide(Config $config): void; +} diff --git a/src/TaskRunner.php b/src/TaskRunner.php index b71a1fe6..5c23d2b6 100644 --- a/src/TaskRunner.php +++ b/src/TaskRunner.php @@ -3,12 +3,17 @@ namespace OpenEuropa\TaskRunner; use Composer\Autoload\ClassLoader; +use Consolidation\AnnotatedCommand\Parser\Internal\DocblockTag; +use Consolidation\AnnotatedCommand\Parser\Internal\TagFactory; +use Consolidation\Config\Loader\ConfigProcessor; use Gitonomy\Git\Repository; use OpenEuropa\TaskRunner\Commands\ChangelogCommands; use OpenEuropa\TaskRunner\Commands\DrupalCommands; use OpenEuropa\TaskRunner\Commands\DynamicCommands; use OpenEuropa\TaskRunner\Commands\ReleaseCommands; +use OpenEuropa\TaskRunner\Commands\RunnerCommands; use OpenEuropa\TaskRunner\Contract\ComposerAwareInterface; +use OpenEuropa\TaskRunner\Contract\ConfigProviderInterface; use OpenEuropa\TaskRunner\Contract\RepositoryAwareInterface; use OpenEuropa\TaskRunner\Contract\TimeAwareInterface; use OpenEuropa\TaskRunner\Services\Composer; @@ -72,6 +77,7 @@ class TaskRunner ChangelogCommands::class, DrupalCommands::class, ReleaseCommands::class, + RunnerCommands::class, ]; /** @@ -89,11 +95,13 @@ public function __construct(InputInterface $input, OutputInterface $output, Clas $this->workingDir = $this->getWorkingDir($this->input); chdir($this->workingDir); - $this->config = $this->createConfiguration(); + $this->config = new Config(); $this->application = $this->createApplication(); $this->application->setAutoExit(false); $this->container = $this->createContainer($this->input, $this->output, $this->application, $this->config, $classLoader); + $this->createConfiguration(); + // Create and initialize runner. $this->runner = new RoboRunner(); $this->runner->setContainer($this->container); @@ -150,48 +158,92 @@ public function getCommands($class) } /** - * Create default configuration. - * - * @return Config + * Parses the configuration files, and merges them into the Config object. */ private function createConfiguration() { $config = new Config(); $config->set('runner.working_dir', realpath($this->workingDir)); - Robo::loadConfiguration([ - __DIR__.'/../config/runner.yml', - 'runner.yml.dist', - 'runner.yml', - $this->getLocalConfigurationFilepath(), - ], $config); - - return $config; + + foreach ($this->getConfigProviders() as $class) { + /** @var \OpenEuropa\TaskRunner\Contract\ConfigProviderInterface $class */ + $class::provide($config); + } + + // Resolve variables and import into config. + $processor = (new ConfigProcessor())->add($config->export()); + $this->config->import($processor->export()); + // Keep the container in sync. + $this->container->share('config', $this->config); } /** - * Get the local configuration filepath. + * Discovers all config providers. * - * @param string $configuration_file - * The default filepath. + * @return string[] + * An array of fully qualified class names of available config providers. * - * @return string|null - * The local configuration file path, or null if it doesn't exist. + * @throws \ReflectionException + * Thrown if a config provider doesn't have a valid annotation. */ - private function getLocalConfigurationFilepath($configuration_file = 'openeuropa/taskrunner/runner.yml') + private function getConfigProviders(): array { - if ($config = getenv('OPENEUROPA_TASKRUNNER_CONFIG')) { - return $config; - } + /** @var \Robo\ClassDiscovery\RelativeNamespaceDiscovery $discovery */ + $discovery = Robo::service('relativeNamespaceDiscovery'); + $discovery->setRelativeNamespace('TaskRunner\ConfigProviders') + ->setSearchPattern('/.*ConfigProvider\.php$/'); - if ($config = getenv('XDG_CONFIG_HOME')) { - return $config . '/' . $configuration_file; + // Discover config providers. + foreach ($discovery->getClasses() as $class) { + if (is_subclass_of($class, ConfigProviderInterface::class)) { + $classes[$class] = $this->getConfigProviderPriority($class); + } } - if ($home = getenv('HOME')) { - return getenv('HOME') . '/.config/' . $configuration_file; - } + // High priority modifiers run first. + arsort($classes, SORT_NUMERIC); - return null; + return array_keys($classes); + } + + /** + * @param string $class + * @return float + * @throws \ReflectionException + */ + private function getConfigProviderPriority($class) + { + $priority = 0.0; + $reflectionClass = new \ReflectionClass($class); + if ($docBlock = $reflectionClass->getDocComment()) { + // Remove the leading /** and the trailing */ + $docBlock = preg_replace('#^\s*/\*+\s*#', '', $docBlock); + $docBlock = preg_replace('#\s*\*+/\s*#', '', $docBlock); + + // Nothing left? Exit. + if (empty($docBlock)) { + return $priority; + } + + $tagFactory = new TagFactory(); + foreach (explode("\n", $docBlock) as $row) { + // Remove trailing whitespace and leading space + '*'s + $row = rtrim($row); + $row = preg_replace('#^[ \t]*\**#', '', $row); + $tagFactory->parseLine($row); + } + + $priority = array_reduce($tagFactory->getTags(), function ($priority, DocblockTag $tag) { + if ($tag->getTag() === 'priority') { + $value = $tag->getContent(); + if (is_numeric($value)) { + $priority = (float) $value; + } + } + return $priority; + }, $priority); + } + return $priority; } /** diff --git a/src/TaskRunner/ConfigProviders/DefaultConfigProvider.php b/src/TaskRunner/ConfigProviders/DefaultConfigProvider.php new file mode 100644 index 00000000..f1483dca --- /dev/null +++ b/src/TaskRunner/ConfigProviders/DefaultConfigProvider.php @@ -0,0 +1,42 @@ +export(), $loader->load($file)->export()); + $config->replace($configArray); + } + } +} diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 6f5478dc..2a71fcd0 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -343,15 +343,9 @@ public function testWorkingDirectoryToken() */ public function testUserConfigFile() { - $fixtureName = 'userconfig.yml'; - - // Create a runner. - $input = new StringInput('list --working-dir=' . $this->getSandboxRoot()); - $runner = new TaskRunner($input, new NullOutput(), $this->getClassLoader()); - $runner->run(); - - // Extract a value from the default configuration. - $drupalRoot = $runner->getConfig()->get('drupal.root'); + // Create a local config file. + $runnerYaml = $this->getSandboxRoot().'/runner.yml'; + file_put_contents($runnerYaml, Yaml::dump(['foo' => 'baz'])); // Add the environment setting. putenv('OPENEUROPA_TASKRUNNER_CONFIG=' . __DIR__ . '/fixtures/userconfig.yml'); @@ -359,14 +353,28 @@ public function testUserConfigFile() // Create a new runner. $input = new StringInput('list --working-dir=' . $this->getSandboxRoot()); $runner = new TaskRunner($input, new NullOutput(), $this->getClassLoader()); - $runner->run(); - // Get the content of the fixture. - $content = $this->getFixtureContent($fixtureName); + // Set as `build` by `config/runner.yml`. + // Overwritten as `drupal` by `tests/fixtures/userconfig.yml`. + $this->assertEquals('drupal', $runner->getConfig()->get('drupal.root')); + + // Set as `['root' => 'drupal']` by `TestConfigProvider::provide()`. + // Overwritten as `['root' => 'wordpress']` by `userconfig.yml`. + $this->assertSame(['root' => 'wordpress'], $runner->getConfig()->get('wordpress')); + + // Set as `['root' => 'joomla']` by `tests/fixtures/third_party.yml`. + $this->assertSame(['root' => 'joomla'], $runner->getConfig()->get('joomla')); + + // Set as `overwritten by edge case` by `tests/fixtures/userconfig.yml`. + // Overwritten as `overwritten` by `EdgeCaseConfigProvider::provide()`. + $this->assertSame('overwritten', $runner->getConfig()->get('whatever')); - $this->assertEquals($content['wordpress'], $runner->getConfig()->get('wordpress')); - $this->assertEquals($content['drupal']['root'], $runner->getConfig()->get('drupal.root')); - $this->assertNotEquals($drupalRoot, $runner->getConfig()->get('drupal.root')); + // The `qux` value is computed by using the `${foo}` token. We test + // that the replacements are done at the very end, when all the config + // providers had the chance to resolve the tokens. `${foo}` equals + // `bar`, in the `tests/fixtures/third_party.yml` file but is + // overwritten at the end, in `tests/sandbox/runner.yml` with `baz`. + $this->assertSame('is-baz', $runner->getConfig()->get('qux')); } /** diff --git a/tests/custom/src/TaskRunner/ConfigProviders/EdgeCaseConfigProvider.php b/tests/custom/src/TaskRunner/ConfigProviders/EdgeCaseConfigProvider.php new file mode 100644 index 00000000..0c976f95 --- /dev/null +++ b/tests/custom/src/TaskRunner/ConfigProviders/EdgeCaseConfigProvider.php @@ -0,0 +1,25 @@ +set('whatever', 'overwritten'); + } +} diff --git a/tests/custom/src/TaskRunner/ConfigProviders/TestConfigProvider.php b/tests/custom/src/TaskRunner/ConfigProviders/TestConfigProvider.php new file mode 100644 index 00000000..7b88bbef --- /dev/null +++ b/tests/custom/src/TaskRunner/ConfigProviders/TestConfigProvider.php @@ -0,0 +1,30 @@ +set('whatever.root', 'drupal'); + // Interleave third_party.yml between runner.yml.dist and runner.yml. + static::importFromFiles($config, [ + __DIR__ . '/../../../../fixtures/third_party.yml', + ]); + } +} diff --git a/tests/fixtures/third_party.yml b/tests/fixtures/third_party.yml new file mode 100644 index 00000000..32dcc9d9 --- /dev/null +++ b/tests/fixtures/third_party.yml @@ -0,0 +1,7 @@ +joomla: + root: joomla + +foo: bar +# Should be 'is-baz' as the variables should be resolved only at the end, when +# all config providers had the chance to provide token values. +qux: is-${foo} diff --git a/tests/fixtures/userconfig.yml b/tests/fixtures/userconfig.yml index c0e02103..be93de1c 100644 --- a/tests/fixtures/userconfig.yml +++ b/tests/fixtures/userconfig.yml @@ -3,3 +3,5 @@ drupal: wordpress: root: "wordpress" + +whatever: 'this will be overwritten by the edge case provider'