diff --git a/.horde.yml b/.horde.yml index aaee5906..89fb7f20 100644 --- a/.horde.yml +++ b/.horde.yml @@ -55,3 +55,10 @@ dependencies: pear.phpunit.de/phpdcd: '*' pear.phpunit.de/phploc: '*' pear.phpmd.org/PHP_PMD: '*' +autoload: + psr-0: + Components: 'lib/' + psr-4: + 'Horde\Components\': 'src/' +nocommands: + - 'bin/horde-bootstrap' diff --git a/bin/horde-components b/bin/horde-components index 1becc747..93cd47bb 100755 --- a/bin/horde-components +++ b/bin/horde-components @@ -11,6 +11,20 @@ if (defined('E_DEPRECATED')) { } else { error_reporting(E_ALL & ~E_STRICT); } +// horde-components standalone run in composer setups +$autoloaders = [ + __DIR__ . '/../config/autoload.php', // custom autoload config + __DIR__ . '/../vendor/autoload.php', // components is root app + __DIR__ . '/../../../vendor/autoload.php', // components is installed in a horde-deployment + // handle "git developer install" and pear cases + 'Horde/Autoloader/Default.php' +]; -require_once 'Horde/Autoloader/Default.php'; +foreach ($autoloaders as $autoloader) { + if (file_exists($autoloader)) { + require_once $autoloader; + break; + } +} +// in case of calling components via git-tools, this file plays no role Components::main(); diff --git a/composer.json b/composer.json index d926d357..47f18616 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "horde/components", "description": "Developer tool for managing Horde components", - "type": "project", + "type": "horde-application", "homepage": "https://www.horde.org", "license": "LGPL-2.1", "authors": [ @@ -17,30 +17,43 @@ } ], "version": "1.0.0", - "time": "2020-03-15", + "time": "2020-10-15", "repositories": [ { - "type": "pear", - "url": "https://pear.horde.org" + "type": "composer", + "url": "https://horde-satis.maintaina.com" + }, + { + "url": "https://pear.php.net", + "type": "pear" + }, + { + "url": "https://pear.phpunit.de", + "type": "pear" + }, + { + "url": "https://pear.phpmd.org", + "type": "pear" } ], "require": { + "horde/horde-installer-plugin": "dev-master", "php": "^5.4 || ^7", - "pear-pear.horde.org/Horde_Autoloader": "*", - "pear-pear.horde.org/Horde_Argv": "*", - "pear-pear.horde.org/Horde_Cli": "^2.3", - "pear-pear.horde.org/Horde_Cli_Modular": "^2.1", - "pear-pear.horde.org/Horde_Http": "*", - "pear-pear.horde.org/Horde_Pear": "*", - "pear-pear.horde.org/Horde_Role": "*", - "pear-pear.horde.org/Horde_Text_Diff": "*", - "pear-pear.horde.org/Horde_Util": "*", - "pear-pear.horde.org/Horde_Injector": "*", - "pear-pear.horde.org/Horde_Release": "*", - "pear-pear.horde.org/Horde_Yaml": "*" + "horde/autoloader": "dev-master", + "horde/argv": "dev-master", + "horde/cli": "dev-master", + "horde/cli_modular": "dev-master", + "horde/http": "dev-master", + "horde/pear": "dev-master", + "horde/role": "dev-master", + "horde/text_diff": "dev-master", + "horde/util": "dev-master", + "horde/injector": "dev-master", + "horde/release": "dev-master", + "horde/yaml": "dev-master" }, "suggest": { - "pear-pear.horde.org/Horde_Test": "*", + "horde/test": "*", "pear-pear.php.net/Archive_Tar": "*", "pear-pear.php.net/PHP_CodeSniffer": "*", "pear-pear.phpunit.de/PHPUnit": "*", @@ -49,13 +62,15 @@ "pear-pear.phpunit.de/phploc": "*", "pear-pear.phpmd.org/PHP_PMD": "*" }, - "replace": { - "pear-pear.horde.org/components": "1.*", - "pear-horde/components": "1.*" - }, "autoload": { "psr-0": { "Components": "lib/" + }, + "psr-4": { + "Horde\\Components\\": "src/" } - } -} + }, + "bin": [ + "bin/horde-components" + ] +} \ No newline at end of file diff --git a/config/conf.php.dist b/config/conf.php.dist index c8de2079..1fe58c08 100644 --- a/config/conf.php.dist +++ b/config/conf.php.dist @@ -8,6 +8,8 @@ * Strings should be enclosed in 'quotes'. * Integers should be given literally (without quotes). * Boolean values may be true or false (never quotes). + * + * PHP Version 5 */ /* PEAR server name. Only change for testing purposes. */ @@ -28,3 +30,180 @@ $conf['from'] = 'Full name '; /* Path to a checkout of the horde-web repository. */ $conf['web_dir'] = '/var/www/horde-web'; + +/** + * Vendor: Used by the composer, packagist, satis and git-related tasks + * Defaults to 'horde' if unset + */ +// $conf['vendor'] = 'horde'; + +/** + * Git Repo Base: The base URL for your remote git repo + * Defaults to https://github.com/$vendor if unset + * If you configure this, $vendor will NOT be appended + */ +//$conf['git_repo_base'] = 'https://git.mycompany.com/level/nextlevel/'; + +/** composer_repo - Type of loader hints to generate + * Default is not to generate any hints. Dependencies will be from packagist + */ +// generate a vcs repo source per horde internal dependency +// $conf['composer_repo'] = 'vcs'; +// generate a satis source for all horde namespace +// $conf['composer_repo'] = 'satis:https://satis.horde.org'; + +/** + * composer_version - tweak all dependency versions + * to a common branch name or version + * This is used by the horde-components composer command and + * the composer release task unless overwritten there + * + * Default: Depend on versions from .horde.yml + */ +// depend on master branch +// $conf['composer_version'] = 'dev-master'; +// depend on a staging branch - components won't check if it even exists! +// $conf['composer_version'] = 'dev-staging'; + + +/* Well known composer native substitutes for pear dependencies */ +$conf['composer_opts']['pear-substitutes'] = [ + ['pear.php.net/Archive_Tar' => + ['source' => 'Packagist', 'name' => 'pear/archive_tar']], + ['pear.php.net/Console_Color2' => + ['source' => 'Packagist', 'name' => 'pear/console_color2']], + ['pear.php.net/Console_Table' => + ['source' => 'Packagist', 'name' => 'pear/table']], + ['pear.php.net/PHP_CodeSniffer' => + ['source' => 'Packagist', 'name' => 'squizlabs/php_codesniffer']], + ['pear.phpunit.de/phpcpd' => ['source' => 'Packagist', 'name' => 'sebastian/phpcpd']], + ['pear.phpunit.de/phpdcd' => ['source' => 'Packagist', 'name' => 'sebastian/phpdcd']], + ['pear.phpunit.de/phploc' => ['source' => 'Packagist', 'name' => 'phploc/phploc']] +]; + +/** + * Configure release pipelines + */ + +/** + * Upstream pipeline + * + * Usage: horde-components path/to/component release for upstream + * + * The upstream pipeline is similar to the original pipeline + * + * Added Steps: + * - checkout of the correct branch, prevent accidental release from master + * - writing an updated composer file + * - Pushing to github + * - Notifying to Packagist + * + * Removed Steps: + * - None + * + */ +$conf['pipeline']['release']['upstream'] = [ + // Checkout the FRAMEWORK_5_2 branch, don't accidentally release master + ['name' => 'GitBranch', 'options' => [ + 'git_branch' => 'FRAMEWORK_5_2', + 'git_branch_prereq' => false + ] + ], + ['name' => 'Unit'], + ['name' => 'Changelog'], + ['name' => 'Timestamp'], + ['name' => 'CurrentSentinel'], + // By default, upload to Pirum server after generating + ['name' => 'Package', 'options' => ['upload' => 'true']], + + // The composer task will use global options like composer_opts, + // composer_version, composer_repo unless explicitly overwritten here + ['name' => 'Composer'], + ['name' => 'CommitPreRelease'], + ['name' => 'TagRelease'], + ['name' => 'Announce'], + ['name' => 'Website'], + ['name' => 'Bugs'], + ['name' => 'NextVersion'], + ['name' => 'CommitPostRelease'], + ['name' => 'GitPush'], + // Nudge Packagist that we have an update + // Requires API key. Does not work for new packages not yet registered. + ['name' => 'Packagist', 'options' => [ + 'packagist_api_key' => '', + 'packagist_user' => 'horde', + 'vendor' => 'horde' + ] + ] + // Optional: A local Satis installation satis.horde.org + // satis_bin: path to satis cli + // satis_json: path to satis json file + // satis_outdir: path where satis should write the repository + // satis_push: If the output dir is a git checkout, try to commit and push? +/* ['name' => 'Satis', 'options' => [ + 'satis_bin' => '', + 'satis_json' => '', + 'satis_outdir' => '', + 'satis_push' => true + ] + ]*/ +]; + +/** + * Downstream pipeline + * + * Usage: horde-components path/to/component release for downstream + * + * The downstream pipeline is for developing horde based code in your own lab + * + * Added Steps: + * - checkout of the correct branch, prevent accidental release from master + * - writing an updated composer file + * - Pushing to github + * - Writing to a satis repository + * + * Removed Steps: + * - Package/Upload (Pear) + * - Announce + * - Website + * - Bugs + * + */ +$conf['pipeline']['release']['downstream'] = [ + // Ensure we create release from the staging branch. Break if not. + ['name' => 'GitBranch', 'options' => [ + 'git_branch' => 'staging', + 'git_branch_prereq' => true + ] + ], + ['name' => 'Unit'], + ['name' => 'Changelog'], + ['name' => 'Timestamp'], + ['name' => 'CurrentSentinel'], + // Pear related steps have been removed + // The composer task will use global options like composer_opts, + // composer_version, composer_repo unless explicitly overwritten here + ['name' => 'Composer', 'options' => ['composer_version' => '' ] ], + ['name' => 'CommitPreRelease'], + ['name' => 'TagRelease'], + // Increase version numbers for development mode + ['name' => 'NextVersion'], + // Force our component to require staging branch dependencies + // This is useful for development mode + ['name' => 'Composer', 'options' => ['composer_version' => 'dev-staging' ]], + ['name' => 'CommitPostRelease'], + ['name' => 'GitPush'], + // Our downstream pipeline uses satis rather than packagist + // satis_bin: path to satis cli + // satis_json: path to satis json file + // satis_outdir: path where satis should write the repository + // satis_push: If the output dir is a git checkout, try to commit and push? + ['name' => 'Satis', 'options' => [ + 'satis_bin' => '', + 'satis_json' => '', + 'satis_outdir' => '', + 'satis_push' => true + ] + ] + // TODO: Missing a step to update the release branch from the latest tag +]; \ No newline at end of file diff --git a/lib/Components/Component/Identify.php b/lib/Components/Component/Identify.php index 767469fa..ceece7b0 100644 --- a/lib/Components/Component/Identify.php +++ b/lib/Components/Component/Identify.php @@ -136,7 +136,7 @@ private function _determineComponent($arguments) return array($result, ''); } } - + throw new Components_Exception( sprintf(Components::ERROR_NO_ACTION_OR_COMPONENT, $arguments[0]) ); @@ -144,15 +144,18 @@ private function _determineComponent($arguments) } $cwd = getcwd(); - if ($this->_isDirectory($cwd) && $this->_containsPackageXml($cwd)) { - return array( - $this->_dependencies - ->getComponentFactory() - ->createSource($cwd), - $cwd - ); - } - + // Usability: check if we are in a subdir of a component + do { + if ($this->_isDirectory($cwd) && $this->_containsPackageXml($cwd)) { + return array( + $this->_dependencies + ->getComponentFactory() + ->createSource($cwd), + $cwd + ); + } + $cwd = dirname($cwd, 1); + } while ($cwd != '/'); throw new Components_Exception(Components::ERROR_NO_COMPONENT); } diff --git a/lib/Components/Component/Source.php b/lib/Components/Component/Source.php index f135f2a0..e205d90b 100644 --- a/lib/Components/Component/Source.php +++ b/lib/Components/Component/Source.php @@ -125,7 +125,12 @@ public function getPreviousVersion() if ($found) { // If this is a stable version we want the previous stable version, // otherwise use any previous version. + + // Some older changelog entries may not have the state + // attribute, this may give index errors if ($currentState == 'stable' && + !empty($info['state']) && + !empty($info['state']['release']) && $info['state']['release'] != 'stable') { continue; } @@ -554,77 +559,9 @@ protected function _addDependency($xml, $required, $type, $dependencies) public function updateComposerFromHordeYml() { $yaml = $this->getHordeYml(); - $name = 'horde/' - . str_replace('_', '-', Horde_String::lower($yaml['id'])); - $replaceVersion = preg_replace( - '/^(\d+)\..*/', - '$1.*', - $yaml['version']['release'] - ); - $replacePrefix = $yaml['type'] == 'library' ? 'Horde_' : ''; - $dependencies = array('required' => array(), 'optional' => array()); - foreach ($yaml['dependencies'] as $required => $dependencyTypes) { - foreach ($dependencyTypes as $type => $packages) { - if (!is_array($packages)) { - $dependencies[$required][$type] = $packages; - continue; - } - foreach ($packages as $package => $version) { - if (is_array($version)) { - $version = $version['version']; - } - $dependencies[$required][$type . '-' . $package] = $version; - } - } - } - $authors = array(); - foreach ($yaml['authors'] as $author) { - $authors[] = array( - 'name' => $author['name'], - 'email' => $author['email'], - 'role' => $author['role'], - ); - } - if ($yaml['name'] == 'Core' || - strpos($yaml['name'], 'Horde Groupware') === 0) { - $prefix = 'Horde'; - } elseif ($yaml['type'] == 'library') { - $prefix = 'Horde_' . $yaml['name']; - } else { - $prefix = $yaml['name']; - } - $autoload = array('psr-0' => array($prefix => 'lib/')); - $type = $yaml['type'] == 'library' ? 'library' : 'project'; - $homepage = isset($yaml['homepage']) - ? $yaml['homepage'] - : 'https://www.horde.org'; - - /** @var Components_Wrapper_ComposerJson $json */ - $json = $this->getWrapper('ComposerJson'); - $json->exchangeArray(array_filter(array( - 'name' => $name, - 'description' => $yaml['full'], - 'type' => $type, - 'homepage' => $homepage, - 'license' => $yaml['license']['identifier'], - 'authors' => $authors, - 'version' => $yaml['version']['release'], - 'time' => gmdate('Y-m-d'), - 'repositories' => array( - array( - 'type' => 'pear', - 'url' => 'https://pear.horde.org', - ), - ), - 'require' => $dependencies['required'], - 'suggest' => $dependencies['optional'], - 'replace' => array( - 'pear-pear.horde.org/' . $replacePrefix . $yaml['id'] => $replaceVersion, - 'pear-horde/' . $replacePrefix . $yaml['id'] => $replaceVersion, - ), - 'autoload' => $autoload, - ))); - + $options = $this->_config->getOptions(); + $composer = new Components_Helper_Composer(); + $json = $composer->generateComposerJson($yaml, $options); return $json; } @@ -1266,6 +1203,18 @@ public function getDocDirectory() return $dir; } + /** + * Returns the path to the package's top directory. + * + * This is useful to determine the package dir from inside tasks + * + * @return string The directory name. + */ + public function getComponentDirectory() + { + return $this->_directory; + } + /** * Returns a file wrapper. * diff --git a/lib/Components/Component/Task/Dependencies.php b/lib/Components/Component/Task/Dependencies.php new file mode 100644 index 00000000..36585179 --- /dev/null +++ b/lib/Components/Component/Task/Dependencies.php @@ -0,0 +1,84 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + + +namespace Components\Component\Task; +/** + * Components\Component\Task\Dependencies:: Declare and receive dependencies + * + * Copyright 2011-2019 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +trait Dependencies +{ + protected $_deps = []; + + /** + * Ask for dependencies. + * + * Declares which helpers or other utilities the task wants to have + * + * The request format is an associative array. + * The key will be reused by the answer from outside, the value is a string + * identifying the base class or interface of the dependency. + * + * As long as the injector knows how to provide the dependencies, + * there is no need for individual factories or annotations per task. + * + * Dependencies unfulfilled should be returned as null values for the keys. + * + * The task should decide itself if this is fatal or optional + * + * @return string[] The dictionary of dependencies. + */ + public function askDependencies() + { + return []; + } + + /** + * Receive dependencies + * + * The format contains the same keys ad askDependencies, but the values + * are instances of the requested types or null if unfulfilled. + * + * @param array $dependencies The provided dependencies. + * + * @return void + */ + public function setDependencies(array $dependencies) + { + $this->_deps = $dependencies; + } + + /** + * Retrieve a dependency + * + * It must have been registered by askDependencies and the calling code + * should provide it or null + * + * @param string $key The dependency to get. + * + * @return object|null The dependency or null + */ + public function getDependency($key) + { + return $this->_deps[$key] ?? null; + } +} \ No newline at end of file diff --git a/lib/Components/Component/Task/SystemCall.php b/lib/Components/Component/Task/SystemCall.php new file mode 100644 index 00000000..bb7505ab --- /dev/null +++ b/lib/Components/Component/Task/SystemCall.php @@ -0,0 +1,105 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + + +namespace Components\Component\Task; +/** + * Components\Component\Task\SystemCall:: Run system calls from tasks + * + * Copyright 2011-2019 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +trait SystemCall +{ + /** + * Run a system call. + * + * @param string $call The system call to execute. + * + * @return string The command output. + */ + protected function system($call) + { + if (!$this->getTasks()->pretend()) { + //@todo Error handling + return system($call); + } else { + $this->getOutput()->info(sprintf('Would run "%s" now.', $call)); + } + } + + /** + * Run a system call. + * + * @param string $call The system call to execute. + * @param string $target_dir Run the command in the provided target path. + * + * @return string The command output. + */ + protected function systemInDirectory($call, $target_dir) + { + if (!$this->getTasks()->pretend()) { + $old_dir = getcwd(); + chdir($target_dir); + } + $result = $this->system($call); + if (!$this->getTasks()->pretend()) { + chdir($old_dir); + } + return $result; + } + /** + * Run a system call and capture output. + * + * @param string $call The system call to execute. + * + * @return \Components\Component\Task\SystemCallResult The command output. + */ + protected function exec($call) + { + if (!$this->getTasks()->pretend()) { + exec($call, $output, $retval); + return new SystemCallResult($output, $retval); + } else { + $this->getOutput()->info(sprintf('Would run "%s" now.', $call)); + } + return new SystemCallResult([], 0); + } + + /** + * Run a system call in a given dir and capture output. + * + * @param string $call The system call to execute. + * @param string $target_dir Run the command in the provided target path. + * + * @return \Components\Component\Task\SystemCallResult The command output. + */ + protected function execInDirectory($call, $target_dir) + { + if (!$this->getTasks()->pretend()) { + $old_dir = getcwd(); + chdir($target_dir); + } + $result = $this->exec($call); + if (!$this->getTasks()->pretend()) { + chdir($old_dir); + } + return $result; + } +} \ No newline at end of file diff --git a/lib/Components/Component/Task/SystemCallResult.php b/lib/Components/Component/Task/SystemCallResult.php new file mode 100644 index 00000000..264c1a24 --- /dev/null +++ b/lib/Components/Component/Task/SystemCallResult.php @@ -0,0 +1,72 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + + +namespace Components\Component\Task; +/** + * Components\Component\Task\SystemCallResult:: Holds Output, Return code etc + * + * Copyright 2011-2019 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class SystemCallResult +{ + protected $_fields = []; + + public function __construct(array $stdout, $retval) + { + $this->_fields['stdout'] = $stdout; + $this->_fields['retval'] = $retval; + } + + public function getReturnValue() + { + return $this->_fields['retval']; + } + + /** + * Return multiline command output as single string + * + * @return string Command output as a multiline string + */ + public function getOutputString() + { + return implode("\n", $this->_fields['stdout']); + } + + /** + * Return multiline command output as array of strings + * + * @return string[] Command output as an array per line + */ + public function getOutputArray() + { + return $this->_fields['stdout']; + } + + /** + * Return the output string when used as string + * + * @return string The string value + */ + public function __toString() + { + return $this->getOutputString(); + } +} \ No newline at end of file diff --git a/lib/Components/Helper/Composer.php b/lib/Components/Helper/Composer.php index 9e2b4833..ad973f2a 100644 --- a/lib/Components/Helper/Composer.php +++ b/lib/Components/Helper/Composer.php @@ -1,48 +1,403 @@ + * @author Ralf Lang * @category Horde - * @copyright 2013-2017 Horde LLC + * @copyright 2013-2019 Horde LLC * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 * @package Components */ class Components_Helper_Composer { + + /** + * @var array A list of repositories to add as sources for dependencies + */ + protected $_repositories = array(); + /** + * @var array A list of pear packages to replace by known alternatives + */ + protected $_substitutes = array(); + + /** + * @var string Govern which repos to add to the composer file + * use 'vcs' for an individual git repo per horde dependency + * use 'satis:https://url' for a single composer repo source + * leave empty for no custom repos + */ + protected $_composerRepo = ''; + + /** + * @var string Overwrite horde dependency versions + */ + protected $_composerVersion = ''; + + protected $_vendor = ''; + + protected $_gitRepoBase = ''; + /** * Updates the composer.json file. * - * @param string $package Path to the package.xml file. + * @param \Components_Wrapper_HordeYml $package The package definition * @param array $options The set of options for the operation. */ - public function generateComposeJson($package, array $options = array()) + public function generateComposeJson(\Components_Wrapper_HordeYml $package, array $options = array()) { - require_once __DIR__ . '/../../Conductor/PEARPackageFilev2.php'; - require_once __DIR__ . '/../../Conductor/Package2XmlToComposer.php'; + if (!empty($options['composer_opts']['pear-substitutes'])) + { + $this->_substitutes = $options['composer_opts']['pear-substitutes']; + } + + // Handle cases where vendor is not horde. + $this->_vendor = $options['vendor'] ?? 'horde'; + // The git repo base URL, defaults to github/vendor. + $this->_gitRepoBase = $options['git_repo_base'] ?? + 'https://github.com/' . $this->_vendor . '/'; + // Decide on repo type hints + if (!empty($options['composer_repo'])) { + if ($options['composer_repo'] == 'vcs') { + $this->_composerRepo = 'vcs'; + } + if (substr($options['composer_repo'], 0, 6) == 'satis:') { + $this->_composerRepo = 'composer'; + $this->_repositories['composer'] = [ + 'type' => 'composer', + 'url' => substr($options['composer_repo'], 6) + ]; + } + } + // Override horde dependency versions + if (!empty($options['composer_version'])) { + $this->_composerVersion = $options['composer_version']; + } - $converter = new Package2XmlToComposer($package); - $converter->setRepositories(array( - array('pear', 'https://pear.horde.org') - )); - $converter->output_file = dirname($package) . '/composer.json'; - $converter->convert(); + $filename = dirname($package->getFullPath()) . '/composer.json'; + $composerDefinition = new \stdClass(); + $this->_setName($package, $composerDefinition); + // Is this intentional? "description" seems always longer than full + $composerDefinition->description = $package['full']; + $this->_setType($package, $composerDefinition); + $composerDefinition->homepage = $package['homepage'] ?? 'https://www.horde.org'; + $composerDefinition->license = $package['license']['identifier']; + $this->_setAuthors($package, $composerDefinition); + // cut off any -git or similar + list($version) = explode('-', $package['version']['release']); + $composerDefinition->version = $version; + $composerDefinition->time = (new \Horde_Date(mktime()))->format('Y-m-d'); + $composerDefinition->repositories = []; + $this->_setRequire($package, $composerDefinition); + $this->_setSuggest($package, $composerDefinition); + $this->_setRepositories($package, $composerDefinition); + $this->_setAutoload($package, $composerDefinition); + $this->_setVendorBin($package, $composerDefinition); + // Development dependencies? + // Replaces ? Only needed for special cases. Default cases are handled implicitly + // provides? apps can depend on provided APIs rather than other apps + + // Enforce suggest to be a json object rather than array + if (empty($composerDefinition->suggest)) { + $composerDefinition->suggest = new \stdClass(); + } + $jsonDefinition = json_encode($composerDefinition, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + file_put_contents($filename, $jsonDefinition); if (isset($options['logger'])) { $options['logger']->ok( 'Created composer.json file.' ); } + return $jsonDefinition; } + /** + * Build a list of commands which should be exposed to vendor/bin. + * + * Default to all direct executable files of bindir + * Otherwise use provided whitelist "commands" + * and blacklist "nocommands" (blacklist wins) + */ + protected function _setVendorBin(\Components_Wrapper_HordeYml $package, \stdClass $composerDefinition) + { + $commands = []; + $noCommands = []; + if (!empty($package['nocommands'])) { + $noCommands = $package['nocommands']; + } + /** + * If the package sports an explicit list of commands, use only these + */ + if (!empty($package['commands'])) { + $commands = $package['commands']; + } else { + // No explicit list - search bindir + $binDir = dirname($package->getFullPath()) . '/bin/'; + if (is_dir($binDir)) { + foreach (new \DirectoryIterator($binDir) as $file) { + if ($file->isExecutable() and $file->isFile()) { + $commands[] = 'bin/' . $file->getFilename(); + } + } + } + } + /** + * If the package provides a blacklist, filter. + * + */ + if ($noCommands) { + $commands = array_diff($commands, $noCommands); + } + if ($commands) { + $composerDefinition->bin = array_values($commands); + } + } + + protected function _setName(\Components_Wrapper_HordeYml $package, \stdClass $composerDefinition) + { + $vendor = $this->_vendor; + $name = \Horde_String::lower($package['name']); + $composerDefinition->name = "$vendor/$name"; + } + + protected function _setType(\Components_Wrapper_HordeYml $package, \stdClass $composerDefinition) + { + if ($package['type'] == 'library') { + $composerDefinition->type = 'horde-library'; + } + if ($package['type'] == 'application') { + $composerDefinition->type = 'horde-application'; + } + if ($package['type'] == 'component') { + $composerDefinition->type = 'horde-application'; + } + // No type is perfectly valid for composer. Types for themes, bundles? + } + + protected function _setAuthors(\Components_Wrapper_HordeYml $package, \stdClass $composerDefinition) + { + $composerDefinition->authors = array(); + foreach ($package['authors'] as $author) { + $person = new stdClass(); + $person->name = $author['name']; + $person->email = $author['email']; + $person->role = $author['role']; + array_push($composerDefinition->authors, $person); + } + } + + protected function _setAutoload(\Components_Wrapper_HordeYml $package, \stdClass $composerDefinition) + { + $composerDefinition->autoload = []; + + $name = $package['type'] == 'library' ? 'Horde_' . $package['name'] : $package['name']; + if (!empty($package['autoload'])) { + foreach ($package['autoload'] as $type => $definition) { + if ($type == 'classmap') { + $composerDefinition->autoload['classmap'] = $definition; + } + if ($type == 'psr-0') { + $composerDefinition->autoload['psr-0'] = $definition; + } + if ($type == 'psr-4') { + $composerDefinition->autoload['psr-4'] = $definition; + } + } + } else { + $composerDefinition->autoload['psr-0'] = [$name => 'lib/']; + } + } + + /** + * Convert .horde.yml requirements to composer format + * + * References to the horde pear channel will be changed to composer + * Depending on options, assume horde packages live on either + * - packagist/default repo + * - a satis repo + * - individual git repos + */ + protected function _setRequire(\Components_Wrapper_HordeYml $package, \stdClass $composerDefinition) + { + $version = ($this->_composerVersion) ?: '*'; + $composerDefinition->require = array('horde/horde-installer-plugin' => $version); + + if (empty($package['dependencies']['required'])) { + return; + } + foreach ($package['dependencies']['required'] as $element => $required) { + if ($element == 'composer') { + // composer dependencies which have no pear equivalent, i.e. unbundling + foreach ($required as $dep => $version) { + // Do we need to override versions or the likes here? + $composerDefinition->require[$dep] = $version; + continue; + } + } + if ($element == 'pear') { + foreach ($required as $pear => $version) { + list($repo, $basename) = explode('/', $pear); + // If it's on our substitute whitelist, convert to composer-native + if ($this->_substitute($pear, $version, $composerDefinition->require)) { + continue; + } + // If it's a horde pear component, rather use composer-native and add github vcs as repository + if ($repo == 'pear.horde.org') { + $vendor = $this->_vendor; + if (!empty($this->_composerVersion)) { + $version = $this->_composerVersion; + } + if ($basename == 'horde') { + // the "horde" app lives in the "base" repo. + $repo = 'base'; + } elseif (substr($basename, 0, 6) === 'Horde_') { + $basename = $repo = substr($basename, 6); + } else { + // regular app + $repo = $basename; + } + $this->_handleVersion($version, $composerDefinition->require, 'horde', $repo, $basename, $vendor); + continue; + } + if ($repo == 'pecl.php.net') { + $this->_handleVersion($version, $composerDefinition->require, 'ext', $repo, $basename); + continue; + } + // Else, require from pear and add pear as a source. + $this->_handleVersion($version, $composerDefinition->require, 'pear', $repo, $basename); + } + } + if ($element == 'php') { + $composerDefinition->require[$element] = $required; + } + if ($element == 'ext') { + foreach ($required as $ext => $version) { + $this->_handleVersion($version, $composerDefinition->require, 'ext', $repo, $ext); + } + } + } + } + + // Deal with packages appropriately + protected function _handleVersion($version, &$stack, $type, $repo, $basename, $vendor = '') + { + $ext = ''; + if (is_array($version)) { + $ext = empty($version['providesextension']) ? '' : $version['providesextension']; + $version = empty($version['version']) ? '*' : $version['version']; + } + if ($type == 'ext') { + $ext = $basename; + } + if ($ext) { + $stack['ext-' . $ext] = $version; + } elseif ($type == 'pear') { + $stack['pear-' . "$repo/$basename"] = $version; + $this->_repositories['pear-' . $repo] = ['url' => 'https://' . $repo, 'type' => 'pear']; + } else { + // Most likely, this is always composer + $stack[\Horde_String::lower("$vendor/$basename")] = $version; + if ($this->_composerRepo == 'vcs') { + $this->_repositories["$vendor/$basename"] = ['url' => "https://github.com/$vendor/$repo", 'type' => 'vcs']; + } + } + } + + protected function _addPearRepo($pear) + { + $repo = substr($pear, 0, strrpos($pear, '/')); + $this->_repositories['pear-' . $repo] = ['url' => 'https://' . $repo, 'type' => 'pear']; + } + + /** + * Convert .horde.yml suggestions to composer format + * + * References to the horde pear channel will be changed to composer vcs/github + */ + protected function _setSuggest(\Components_Wrapper_HordeYml $package, \stdClass $composerDefinition) + { + $composerDefinition->suggest = array(); + if (empty($package['dependencies']['optional'])) { + return; + } + foreach ($package['dependencies']['optional'] as $element => $suggested) { + if ($element == 'composer') { + // composer dependencies which have no pear equivalent, i.e. unbundling + foreach ($suggested as $dep => $version) { + // Do we need to override versions or the likes here? + $composerDefinition->suggest[$dep] = $version; + continue; + } + } + if ($element == 'pear') { + foreach ($suggested as $pear => $version) { + list($repo, $basename) = explode('/', $pear); + // If it's on our substitute whitelist, convert to composer-native + if ($this->_substitute($pear, $version, $composerDefinition->suggest)) { + continue; + } + // If it's a horde pear component, rather use composer-native and add github vcs as repository + if ($repo == 'pear.horde.org') { + $vendor = $this->_vendor; + if ($basename == 'horde') { + // the "horde" app lives in the "base" repo. + $repo = 'base'; + } elseif(substr($basename, 0, 6) === 'Horde_') { + $basename = $repo = substr($basename, 6); + } else { + // regular app + $repo = $basename; + } + $this->_handleVersion($version, $composerDefinition->suggest, 'horde', $repo, $basename, $vendor); + continue; + } + if ($repo == 'pecl.php.net') { + $this->_handleVersion($version, $composerDefinition->suggest, 'ext', $repo, $basename); + continue; + } + // Else, take from pear and add pear as a source. + $this->_handleVersion($version, $composerDefinition->suggest, 'pear', $repo, $basename); + } + } + if ($element == 'php') { + $composerDefinition->suggest[$element] = $suggested; + } + if ($element == 'ext') { + foreach ($suggested as $ext => $version) { + $this->_handleVersion($version, $composerDefinition->suggest, 'ext', $repo, $ext); + } + } + } + + } + + // Handle the substitution list + protected function _substitute($pear, $version, &$stack) + { + if (!empty($this->_substitutes[$pear])) { + $stack[$this->_substitutes[$pear]['name']] = $version; + if ($this->_substitutes[$pear]['source'] != 'Packagist') + { + throw new \Components_Exception("Non-Packagist substitutes not yet implemented:" . $this->_substitutes[$pear]['source']); + } + return true; + } + return false; + } + + protected function _setRepositories(\Components_Wrapper_HordeYml $package, \stdClass $composerDefinition) + { + $composerDefinition->repositories = array_values($this->_repositories); + } } diff --git a/lib/Components/Helper/Version.php b/lib/Components/Helper/Version.php index 21b0620b..4394165e 100644 --- a/lib/Components/Helper/Version.php +++ b/lib/Components/Helper/Version.php @@ -32,6 +32,42 @@ class Components_Helper_Version */ public static function validatePear($version) { + // Guard: Some older changelogs may contain only two-part versions like + // Horde 3.3 or Horde 3.3RC1 - These should be re-interpreted as three + // part versions .0 before going on. Also lowercase ALPHA, BETA and + // remove hyphens in all the wrong places. + if (preg_match('/^(\d+\.\d+)(-git|alpha\d*|beta\d*|ALPHA\d*|BETA\d*|-ALPHA\d*|-BETA\d*|-RC\d+|RC\d+)?$/', $version, $match)) { + if (empty($match[2])) { + $match[2] = ''; + } else { + $match[2] = str_replace( + [ + 'ALPHA', '-ALPHA', '-alpha', + 'BETA', '-BETA', '-beta', + '-RC' + ], + [ + 'alpha', 'alpha','alpha', + 'beta', 'beta', 'beta', + 'RC' + ], + $match[2] + ); + } + print($match[2]); + // make bare alpha/beta/rc version 1 each + if (in_array($match[2], ['alpha', 'beta', 'RC'])) { + $match[2] = '1'; + } + $version = $match[1] . '.0' . $match[2]; + } + // We also had horde version 2.2.6-RC1 - make this 2.2.6RC1 + if (preg_match('/^(\d+\.\d+\.\d+)(-RC\d+)?$/', $version, $match) && + !empty($match[2])) { + $match[2] = substr($match[2], 1); + $version = $match[1] . $match[2]; + } + // Now version must be proper or croak if (!preg_match('/^(\d+\.\d+\.\d+)(-git|alpha\d*|beta\d*|RC\d+)?$/', $version, $match)) { throw new Components_Exception('Invalid version number ' . $version); } diff --git a/lib/Components/Qc/Task/Base.php b/lib/Components/Qc/Task/Base.php index aa2c2880..dfe9c2e3 100644 --- a/lib/Components/Qc/Task/Base.php +++ b/lib/Components/Qc/Task/Base.php @@ -169,41 +169,5 @@ public function run(&$options) { } - /** - * Run a system call. - * - * @param string $call The system call to execute. - * - * @return string The command output. - */ - protected function system($call) - { - if (!$this->getTasks()->pretend()) { - //@todo Error handling - return system($call); - } else { - $this->getOutput()->info(sprintf('Would run "%s" now.', $call)); - } - } - - /** - * Run a system call. - * - * @param string $call The system call to execute. - * @param string $target_dir Run the command in the provided target path. - * - * @return string The command output. - */ - protected function systemInDirectory($call, $target_dir) - { - if (!$this->getTasks()->pretend()) { - $old_dir = getcwd(); - chdir($target_dir); - } - $result = $this->system($call); - if (!$this->getTasks()->pretend()) { - chdir($old_dir); - } - return $result; - } + use \Components\Component\Task\SystemCall; } \ No newline at end of file diff --git a/lib/Components/Qc/Task/Unit.php b/lib/Components/Qc/Task/Unit.php index 35bff569..35bb3242 100644 --- a/lib/Components/Qc/Task/Unit.php +++ b/lib/Components/Qc/Task/Unit.php @@ -47,7 +47,10 @@ public function getName() public function validate($options) { if (!class_exists('Horde_Test_AllTests')) { - return array('PHPUnit is not available!'); + return array('Horde_Test is not installed!'); + } + if (!class_exists('PHPUnit_Runner_BaseTestRunner') && !class_exists('PHPUnit\Runner\BaseTestRunner')) { + return array('PHPUnit is not installed!'); } } diff --git a/lib/Components/Release/Task/Base.php b/lib/Components/Release/Task/Base.php index e5b6dd26..12882076 100644 --- a/lib/Components/Release/Task/Base.php +++ b/lib/Components/Release/Task/Base.php @@ -196,41 +196,6 @@ public function run(&$options) { } - /** - * Run a system call. - * - * @param string $call The system call to execute. - * - * @return string The command output. - */ - protected function system($call) - { - if (!$this->getTasks()->pretend()) { - //@todo Error handling - return system($call); - } else { - $this->getOutput()->info(sprintf('Would run "%s" now.', $call)); - } - } - - /** - * Run a system call. - * - * @param string $call The system call to execute. - * @param string $target_dir Run the command in the provided target path. - * - * @return string The command output. - */ - protected function systemInDirectory($call, $target_dir) - { - if (!$this->getTasks()->pretend()) { - $old_dir = getcwd(); - chdir($target_dir); - } - $result = $this->system($call); - if (!$this->getTasks()->pretend()) { - chdir($old_dir); - } - return $result; - } + use \Components\Component\Task\SystemCall; + use \Components\Component\Task\Dependencies; } diff --git a/lib/Components/Release/Task/Changelog.php b/lib/Components/Release/Task/Changelog.php index 13dcdbfd..8d246fdf 100644 --- a/lib/Components/Release/Task/Changelog.php +++ b/lib/Components/Release/Task/Changelog.php @@ -59,7 +59,7 @@ public function postValidate($options) $diff = $this->getComponent()->updatePackage('diff', $diff_options); if (!empty($diff)) { return array( - "The package.xml file is not up-to-date:\n$diff" + "At least one metadata file file is not up-to-date:\n$diff" ); } return array(); diff --git a/lib/Components/Release/Task/Composer.php b/lib/Components/Release/Task/Composer.php new file mode 100644 index 00000000..fbab7102 --- /dev/null +++ b/lib/Components/Release/Task/Composer.php @@ -0,0 +1,50 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +/** + * Components_Release_Task_Composer:: Update the composer file + * + * Copyright 2011-2019 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class Components_Release_Task_Composer +extends Components_Release_Task_Base +{ + /** + * Run the task. + * + * Checkout the wanted branch + * Supports pretend mode + * + * @param array $options Additional options by reference. + * + * @return void; + */ + public function run(&$options) + { + $composer = new Components_Helper_Composer(); + $options['logger'] = $this->getOutput(); + + $composer->generateComposeJson( + $this->getComponent()->getHordeYml(), + $options + ); + return; + } +} diff --git a/lib/Components/Release/Task/GitBranch.php b/lib/Components/Release/Task/GitBranch.php new file mode 100644 index 00000000..c4d52110 --- /dev/null +++ b/lib/Components/Release/Task/GitBranch.php @@ -0,0 +1,206 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +/** + * Components_Release_Task_GitBranch:: Check or enforce a branch checkout + * + * Copyright 2011-2019 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class Components_Release_Task_GitBranch +extends Components_Release_Task_Base +{ + /** + * Run the task. + * + * Checkout the wanted branch + * Supports pretend mode + * + * @param array $options Additional options by reference. + * + * @return void; + */ + public function run(&$options) + { + // use master branch unless otherwise stated + $wanted = $options['git_branch'] ?? 'master'; + $current = $this->_currentBranch(); + // save the current git branch to options + // unless another one was saved before (running twice) + $options['git_orig_branch'] = $options['git_orig_branch'] ?? $current; + if ($current == $wanted) { + $this->getOutput()->info( + sprintf('Branch "%s" is checked out.', $wanted) + ); + return; + } + if ($this->getTasks()->pretend()) { + $this->getOutput()->info( + sprintf('Would check out branch "%s".', $wanted) + ); + return; + } + $this->getOutput()->info( + sprintf('Checking out branch "%s".', $wanted) + ); + $this->_checkout($wanted); + return; + } + /** + * Validate if the desired branch actually exists + * + * @param array $options Additional options. + * + * @return array An empty array if all preconditions are met and a list of + * error messages otherwise. + */ + public function preValidate($options) + { + $wantedBranch = $options['git_branch'] ?? 'master'; + // if the branch needs to be already checked out + $prereq = $options['git_branch_prereq'] ?? false; + $issues = []; + $pretend = $this->getTasks()->pretend(); + if ($pretend) { + $git = 'git'; + } else { + $git = $this->_whichGit(); + } + if (empty($git)) { + $issues[] = 'Could not detect installed git binary'; + return $issues; + } + if (!$this->_branchExists($wantedBranch)) { + $issues[] = "The local branch $wantedBranch does not exist"; + } + $currentBranch = $this->_currentBranch(); + if ($prereq && ($wantedBranch != $currentBranch)) { + $issues[] = "Checked out $currentBranch is not $wantedBranch"; + } + // Don't break the pipeline in pretend mode + $issues = $pretend ? [] : $issues; + return $issues; + } + + /** + * Validate if the checkout succeeded (or was not necessary) + * + * A git checkout may fail for multiple reasons, including uncommitted + * changes in the current checkout. We don't want to continue release + * if this happens as all sorts of weird accidents may happen + * + * @param array $options Additional options. + * + * @return array An empty array if all postconditions are met and a list of + * error messages otherwise. + */ + public function postValidate($options) + { + $pretend = $this->getTasks()->pretend(); + $wantedBranch = $options['git_branch'] ?? 'master'; + $issues = []; + $currentBranch = $this->_currentBranch(); + if (!$pretend && ($wantedBranch != $currentBranch)) { + $issues[] = "Checked out $currentBranch is not $wantedBranch"; + } + return $issues; + } + + /** + * This task may not be skipped + * + * @param array $options Not used, signature only + * + * @return boolean Always false, this task may not be skipped + */ + public function skip($options = array()) + { + return false; + } + + /** + * Look for the git binary + * + * @return string|void + * Might make sense to factor out into a git helper for reuse? + */ + protected function _whichGit() + { + return $this->exec('which git', true); + } + + /** + * Get the current branch + * + * @return string current branch + * Might make sense to factor out into a git helper for reuse? + */ + protected function _currentBranch() + { + return $this->execInDirectory( + 'git rev-parse --abbrev-ref HEAD', + $this->getComponent()->getComponentDirectory(), + true + ); + } + + /** + * Check out a branch + * + * @param string $branch The branch to check out + * + * @return string current branch + * Might make sense to factor out into a git helper for reuse? + */ + protected function _checkout($branch) + { + return $this->systemInDirectory( + 'git checkout ' . $branch, + $this->getComponent()->getComponentDirectory(), + true + ); + } + + /** + * Get the list of branch + * + * @return string[] The list of local branches + * Might make sense to factor out into a git helper for reuse? + */ + protected function _getBranches() + { + return $this->execInDirectory( + 'git branch --format "%(refname:short)"', + $this->getComponent()->getComponentDirectory() + )->getOutputArray(); + } + + /** + * Check if a given local branch exists + * + * @param string $branch The branch to check + * + * @return boolean True if the branch exists + * Might make sense to factor out into a git helper for reuse? + */ + protected function _branchExists(string $branch) + { + return in_array($branch, $this->_getBranches()); + } +} diff --git a/lib/Components/Release/Task/GitPush.php b/lib/Components/Release/Task/GitPush.php new file mode 100644 index 00000000..b6f60503 --- /dev/null +++ b/lib/Components/Release/Task/GitPush.php @@ -0,0 +1,112 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +use \Components\Component\Task\SystemCall; +/** + * Components_Release_Task_GitPush:: Push any changes to a remote server + * + * Copyright 2011-2019 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +class Components_Release_Task_GitPush +extends Components_Release_Task_Base +{ + /** + * Run the task. + * + * Push the branch to the desired remote + * Supports pretend mode + * + * @param array $options Additional options by-reference. + * Keys: + * 'remote' Defaults to origin + * 'branch' Default empty, use tracking branch + * + * @return void + */ + public function run(&$options) + { + $remote = $options['remote'] ?? 'origin'; + // use tracking branch name unless otherwise stated + $branch = $options['branch'] ?? ''; + if ($this->getTasks()->pretend()) { + $this->getOutput()->info( + sprintf('Would push to remote "%s".', $remote) + ); + return; + } + $this->getOutput()->info( + sprintf('Push to remote "%s".', $remote) + ); + $res = $this->_push($remote, $branch); + $this->_pushTags($remote, $branch); + if ($res->getReturnValue()) { + $this->getOutput()->fail(sprintf( + "Failed to push with code '%d' and result:\n%s", + $res->getReturnValue(), + $res->getOutputString() + )); + } + } + + /** + * This task may not be skipped + * + * @return boolean Always false, this task may not be skipped + */ + public function skip($options) + { + return false; + } + + /** + * Push a the current checkout to remote + * + * @param string $remote The remote to push to + * @param string $branch The remote branch. Leave empty for tracking branch + * + * @return SystemCallResult The result object + * Might make sense to factor out into a git helper for reuse? + */ + protected function _push(string $remote, string $branch = '') + { + return $this->execInDirectory( + sprintf('git push %s %s', $remote, $branch), + $this->getComponent()->getComponentDirectory() + ); + } + + /** + * Push a the current checkout's tags to remote + * + * @param string $remote The remote to push to + * @param string $branch The remote branch. Leave empty for tracking branch + * + * @return SystemCallResult The result object + * Might make sense to factor out into a git helper for reuse? + */ + protected function _pushTags(string $remote, string $branch = '') + { + return $this->execInDirectory( + sprintf('git push %s %s --tags', $remote, $branch), + $this->getComponent()->getComponentDirectory() + ); + } +} diff --git a/lib/Components/Release/Task/Package.php b/lib/Components/Release/Task/Package.php index 299c7a28..7f042dd6 100644 --- a/lib/Components/Release/Task/Package.php +++ b/lib/Components/Release/Task/Package.php @@ -45,7 +45,6 @@ public function skip($options) * * @return array An empty array if all preconditions are met and a list of * error messages otherwise. - * @throws Components_Exception */ public function preValidate($options) { @@ -111,7 +110,7 @@ public function preValidate($options) * * @param array &$options Additional options. * - * @throws Components_Exception + * @return NULL */ public function run(&$options) { @@ -141,7 +140,7 @@ public function run(&$options) if (!empty($options['upload'])) { $this->system('scp ' . $path . ' ' . $options['releaseserver'] . ':~/'); - $this->system('ssh '. $options['releaseserver'] . ' "umask 0002 && pirum add ' . $options['releasedir'] . ' ~/' . basename($path) . ' && rm ' . basename($path) . '"'); + $this->system('ssh '. $options['releaseserver'] . ' "umask 0002 && pirum add ' . $options['releasedir'] . ' ~/' . basename($path) . ' && rm ' . basename($path) . '"') . "\n"; if (!$this->getTasks()->pretend()) { unlink($path); } diff --git a/lib/Components/Release/Task/Packagist.php b/lib/Components/Release/Task/Packagist.php new file mode 100644 index 00000000..7e60e9f2 --- /dev/null +++ b/lib/Components/Release/Task/Packagist.php @@ -0,0 +1,185 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @link https://packagist.org/about#how-to-update-packages + */ + +/** + * Components_Release_Task_Packagist:: Notify Packagist of update + * + * Copyright 2011-2019 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @link https://packagist.org/about#how-to-update-packages + */ +class Components_Release_Task_Packagist +extends Components_Release_Task_Base +{ + /** + * Validate if we can make a valid request + * + * @param array $options Additional options. + * + * @return array An empty array if all preconditions are met and a list of + * error messages otherwise. + */ + public function preValidate($options) + { + $issues = []; + $pretend = $this->getTasks()->pretend(); + $package = $this->getComponent(); + $options = $this->_options($options); + if (empty($options['packagist_api_key'])) { + $issues[] = "Did not configure packagist_api_key in conf.php"; + } + if (empty($options['packagist_user'])) { + $issues[] = "Did not configure packagist_user in conf.php"; + } + $http = $this->getDependency('http'); + if (empty($http)) { + $issues[] = "Horde_Http_Client not installed"; + } + if ($issues) { + // These above are fatal, no way to progress + return $issues; + } + if ($pretend) { + $this->getOutput()->info( + sprintf( + 'Would check if package %s/%s exists in packagist', + $options['vendor'], + $package->getName() + ) + ); + } elseif ($this->_verifyPackageExists($options, $package)) { + $this->getOutput()->info( + sprintf( + 'Verified package %s/%s exists in packagist', + $options['vendor'], + $package->getName() + ) + ); + } else { + $issues[] = sprintf( + 'Package %s/%s does not exists in packagist', + $options['vendor'], + $package->getName() + ); + } + + return $issues; + } + + /** + * Ask for the Horde_Http_Client dependency + * + * @return array The list of dependencies requested + */ + public function askDependencies() + { + return ['http' => 'Horde_Http_Client']; + } + + /** + * Run the task. + * + * Checkout the wanted branch + * Supports pretend mode + * + * @param array $options Additional options by reference. + * + * @return void; + */ + public function run(&$options) + { + $options = $this->_options($options); + $http = $this->getDependency('http'); + $url = sprintf( + '%s/api/update-package?username=%s&apiToken=%s', + $options['packagist_url'], + $options['packagist_user'], + $options['packagist_api_key'] + ); + // No need to build this with a JSON conversion + $body = sprintf( + '{"repository":{"url":"%s/packages/%s/%s"}}', + $options['packagist_url'], + $options['vendor'], + $this->getComponent()->getName() + ); + $header = ['content-type' => 'application/json']; + $response = $http->post($url, $body, $header); + if (in_array($response->code, ['404', '500'])) { + $this->getOutput()->warn('Notification to packagist failed!'); + $this->getOutput()->warn($response->getBody()); + } + return; + } + + /** + * Ensure default and required options + * + * - vendor defaults to horde + * - packagist_api_key defaults to empty + * - packagist_user defaults to empty + * - packagist_url defaults to https://packagist.org + * + * @param array $options Additional options. + * + * @return array The processed options + */ + protected function _options($options) + { + $options['vendor'] = $options['vendor'] ?? 'horde'; + $options['packagist_api_key'] = $options['packagist_api_key'] ?? ''; + $options['packagist_url'] = $options['packagist_url'] ?? + 'https://packagist.org'; + $options['packagist_user'] = $options['packagist_user'] ?? ''; + return $options; + } + + /** + * Ensure packagist has the package we want to nudge an update for + * + * Packagist has a published method for receiving update hints but + * there is no published way of programmatically adding packages + * + * @param array $options Additional options. + * @param Components_Component_Base $package The package to check + * + * @return boolean True if the package exists + */ + protected function _verifyPackageExists($options, $package) + { + $http = $this->getDependency('http'); + $url = sprintf( + '%s/packages/%s/%s.json', + $options['packagist_url'], + $options['vendor'], + $package->getName() + ); + $response = $http->get($url); + if ($response->code == 404) { + return false; + } + return true; + } +} diff --git a/lib/Components/Release/Task/Satis.php b/lib/Components/Release/Task/Satis.php new file mode 100644 index 00000000..a667aa81 --- /dev/null +++ b/lib/Components/Release/Task/Satis.php @@ -0,0 +1,186 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @link https://packagist.org/about#how-to-update-packages + */ + +/** + * Components_Release_Task_Satis:: Rebuild a satis repo + * + * Copyright 2011-2019 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @link https://packagist.org/about#how-to-update-packages + */ +class Components_Release_Task_Satis +extends Components_Release_Task_Base +{ + /** + * Validate if we are setup + * + * @param array $options Additional options. + * + * @return array An empty array if all preconditions are met and a list of + * error messages otherwise. + */ + public function preValidate($options) + { + $issues = []; + $options = $this->_options($options); + + foreach (['satis_bin', 'satis_json', 'satis_outdir'] as $option) { + if (empty($options[$option])) { + $issues[] = "Required config key '$option' not set"; + } + } + + return $issues; + } + + /** + * Ask for the Horde_Http_Client dependency + * + * @return array The list of dependencies requested + */ + public function askDependencies() + { + return ['http' => 'Horde_Http_Client']; + } + + /** + * Run the task. + * + * Checkout the wanted branch + * Supports pretend mode + * + * @param array $options Additional options by reference. + * + * @return void; + */ + public function run(&$options) + { + $options = $this->_options($options); + $pretend = $this->getTasks()->pretend(); + $package = $this->getComponent(); + // Do we need to generalize this? + $base = $package->getName(); + if ($base == 'horde') { + $base = 'base'; + } + $repo = $options['git_repo_base'] . $base . '.git'; + if ($pretend) { + $this->getOutput()->info( + sprintf( + 'Would try to ensure package %s is present in json config', + $package->getName() + ) + ); + $this->getOutput()->info( + sprintf( + 'Would try to rewrite Satis repository at %s', + $options['satis_outdir'] + ) + ); + if (!empty($options['satis_push'])) { + $this->getOutput()->info( + sprintf( + 'Would try to commit and push Satis repository at %s', + $options['satis_outdir'] + ) + ); + } + return; + } + // Ensure the package is present in the satis repo + $res = $this->exec( + sprintf( + '%s add %s %s', + $options['satis_bin'], + $repo, + $options['satis_json'] + ) + ); + // Any output here? + + // Rebuild the satis repo + $this->getOutput()->info( + sprintf( + 'Rebuilding static content at %s from json config at %s -'. + ' This may take very long', + $options['satis_outdir'], + $options['satis_json'] + ) + ); + $res = $this->exec( + sprintf( + '%s build %s %s', + $options['satis_bin'], + $options['satis_json'], + $options['satis_outdir'] + ) + ); + if ($options['satis_push']) { + $this->execInDirectory( + 'git add index.html packages.json include', + $options['satis_outdir'] + ); + $this->execInDirectory( + sprintf('git commit -m "Updated by %s release at %s"', + $package->getName(), + (new Horde_Date(time(),'UTC'))->toJson() + ), + $options['satis_outdir'] + ); + $this->execInDirectory( + 'git push', + $options['satis_outdir'] + ); + } + + return; + } + + /** + * Ensure default and required options + * + * - satis_bin: path to satis cli defaults to "which" + * - satis_json: path to satis json file defaults to '' + * - satis_outdir: path where satis should write the repository default '' + * - satis_push: Try to commit and push the generated content as git repo? + * Defaults to false + * + * @param array $options Additional options. + * + * @return array The processed options + */ + protected function _options($options) + { + if (empty($options['satis_bin'])) { + $satisWhich = $this->exec('which satis'); + $found = $satisWhich->getReturnValue() ? '' : (string) $satisWhich; + } + $options['satis_bin'] = $options['satis_bin'] ?? $found; + $options['satis_json'] = $options['satis_json'] ?? ''; + $options['satis_outdir'] = $options['satis_outdir'] ?? ''; + $options['satis_push'] = (bool) $options['satis_push'] ?? false; + $options['vendor'] = $options['vendor'] ?? 'horde'; + $options['git_repo_base'] = $options['git_repo_base'] ?? + 'https://github.com/' . $options['vendor'] . '/'; + return $options; + } +} diff --git a/lib/Components/Release/Task/Unit.php b/lib/Components/Release/Task/Unit.php new file mode 100644 index 00000000..0acb8cba --- /dev/null +++ b/lib/Components/Release/Task/Unit.php @@ -0,0 +1,106 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @link https://wiki.horde.org/Doc/Dev/Component/Components + * + * Adapted from original Code by Gunnar Wrobel and Jan Schneider + */ + +/** + * Components_Release_Task_Unit:: Run Quality Checks and Unit Tests + * + * Wraps code originally part of the Release Runner + * + * Copyright 2011-2019 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @link https://wiki.horde.org/Doc/Dev/Component/Components + */ + +class Components_Release_Task_Unit +extends Components_Release_Task_Base +{ + /** + * Validate the preconditions required for this release task. + * + * @param array $options Additional options. + * + * @return array An empty array if all preconditions are met and a list of + * error messages otherwise. + */ + public function preValidate($options) + { + $component = $this->getComponent(); + $unit = $this->getDependency('qc')->getTask('unit', $component); + return $unit->validate($options); + } + + /** + * This task can be skipped + * + * @param array $options Additional options. (Ignored) + * + * @return boolean Always True, can be skipped. + */ + public function skip($options) + { + return true; + } + + /** + * Run the task. + * + * Push the branch to the desired remote + * Supports pretend mode + * + * @param array $options Additional options. + * Keys: + * 'remote' Defaults to origin + * 'branch' Default empty, use tracking branch + * + * @return void + */ + public function postValidate($options) + { + $issues = []; + $component = $this->getComponent(); + $unit = $this->getDependency('qc')->getTask('unit', $component); + if (!$unit->validate($options)) { + $this->_output->info( + 'Running ' . $unit->getName() . ' on ' . $component->getName() + ); + $this->_output->plain(''); + + if ($unit->run($options)) { + $this->_output->warn('Aborting due to unit test errors.'); + $issues[] = 'Aborting due to unit test errors.'; + } + + $this->_output->ok('No problems found in unit test.'); + } + return $issues; + } + + /** + * Ask for the Components_Qc_Tasks dependency + * + * @return array The list of dependencies requested + */ + public function askDependencies() + { + return ['qc' => 'Components_Qc_Tasks']; + } +} diff --git a/lib/Components/Release/Tasks.php b/lib/Components/Release/Tasks.php index 7e2ca793..92948425 100644 --- a/lib/Components/Release/Tasks.php +++ b/lib/Components/Release/Tasks.php @@ -62,8 +62,8 @@ public function __construct( /** * Return the named task. * - * @param string $name The name of the task. - * @param Components_Component $component The component to be released. + * @param string $name The name of the task. + * @param Components_Component $component The component to be released. * * @return Components_Release_Task_Base The task. */ @@ -74,15 +74,24 @@ public function getTask($name, Components_Component $component) ); $task->setComponent($component); $task->setName($name); + $deps = []; + foreach ($task->askDependencies() as $key => $dependency) { + try { + $deps[$key] = $this->_dependencies->getInstance($dependency); + } catch (Horde_Exception $e) { + // what to do here? + } + } + $task->setDependencies($deps); return $task; } /** * Run a sequence of release tasks. * - * @param array $sequence The task sequence. + * @param array $sequence The task sequence. * @param Components_Component $component The component to be released. - * @param array $options Additional options. + * @param array $options Additional options. * * @return NULL * @throws Components_Exception @@ -94,21 +103,42 @@ public function run( ) { $this->_options = $options; $this->_sequence = $sequence; - - $task_sequence = array(); - foreach ($sequence as $name) { - $task_sequence[] = $this->getTask($name, $component); + $taskSequence = array(); + // check for predefined pipelines + if ((count($sequence) == 2) && + $sequence[0] == 'pipeline:' + ) { + $pipeline = $sequence[1]; + $this->_dependencies->getOutput()->info("Running Pipeline $pipeline"); + foreach ($options['pipeline']['release'][$pipeline] as $task) + { + $taskSequence[] = $this->getTask($task['name'], $component); + if (in_array($task['name'], ['CommitPreRelease', 'CommitPostRelease'])) { + $options['commit'] = new Components_Helper_Commit( + $this->_dependencies->getOutput(), $options + ); + } + $extraOptions[] = empty($task['options']) ? [] : $task['options']; + } + } else { + // default or commandline sequences + foreach ($sequence as $name) { + $taskSequence[] = $this->getTask($name, $component); + // ensure old and new format work the same + $extraOptions[] = []; + } } - $selected_tasks = array(); - foreach ($task_sequence as $task) { - $task_errors = $task->preValidate($options); - if (!empty($task_errors)) { - if ($task->skip($options)) { + $selectedTasks = array(); + foreach ($taskSequence as $index => $task) { + $taskOptions = array_merge($options, $extraOptions[$index]); + $taskErrors = $task->preValidate($taskOptions); + if (!empty($taskErrors)) { + if ($task->skip($taskOptions)) { $this->_dependencies->getOutput()->warn( sprintf( "Deactivated task \"%s\":\n\n%s", $task->getName(), - join("\n", $task_errors) + join("\n", $taskErrors) ) ); } else { @@ -116,12 +146,13 @@ public function run( sprintf( "Precondition for task \"%s\" failed:\n\n%s", $task->getName(), - join("\n", $task_errors) + join("\n", $taskErrors) ) ); } } else { - $selected_tasks[] = $task; + $selectedTasks[] = $task; + $selectedOptions[] = $taskOptions; } } if (!empty($errors)) { @@ -129,17 +160,17 @@ public function run( "Unable to release:\n\n" . join("\n", $errors) ); } - foreach ($selected_tasks as $task) { - $task->run($options); - } - foreach ($selected_tasks as $task) { - $task_errors = $task->postValidate($options); - if (!empty($task_errors)) { + foreach ($selectedTasks as $index => $task) { + $taskOptions = $selectedOptions[$index]; + $task->run($taskOptions); + $taskErrors = $task->postValidate($taskOptions); + if (!empty($taskErrors)) { $this->_dependencies->getOutput()->fail( sprintf( - "Task \"%s\" failed:\n\n%s", + "Task %d \"%s\" failed:\n\n%s", + $index, $task->getName(), - join("\n", $task_errors) + join("\n", $taskErrors) ) ); } diff --git a/lib/Components/Runner/Composer.php b/lib/Components/Runner/Composer.php index b898be8f..98cd2fb6 100644 --- a/lib/Components/Runner/Composer.php +++ b/lib/Components/Runner/Composer.php @@ -25,27 +25,27 @@ class Components_Runner_Composer /** * The configuration for the current job. * - * @var Components_Config + * @var \Components_Config */ private $_config; /** * The composer helper. * - * @var Components_Helper_Composer + * @var \Components_Helper_Composer */ private $_output; /** * Constructor. * - * @param Components_Config $config The configuration for the current + * @param \Components_Config $config The configuration for the current * job. - * @param Components_Output $output The output handler. + * @param \Components_Output $output The output handler. */ public function __construct( - Components_Config $config, - Components_Output $output + \Components_Config $config, + \Components_Output $output ) { $this->_config = $config; $this->_output = $output; @@ -53,12 +53,12 @@ public function __construct( public function run() { - $composer = new Components_Helper_Composer(); + $composer = new \Components_Helper_Composer(); $options = $this->_config->getOptions(); $options['logger'] = $this->_output; $composer->generateComposeJson( - $this->_config->getComponent()->getPackageXmlPath(), + $this->_config->getComponent()->getHordeYml(), $options ); } diff --git a/lib/Components/Runner/Release.php b/lib/Components/Runner/Release.php index 9794200b..3a00a10a 100644 --- a/lib/Components/Runner/Release.php +++ b/lib/Components/Runner/Release.php @@ -86,21 +86,29 @@ public function run() $pre_commit = false; - if ($this->_doTask('unittest')) { - $unit = $this->_qc->getTask('unit', $component); - if (!$unit->validate($options)) { - $this->_output->info( - 'Running ' . $unit->getName() . ' on ' . $component->getName() - ); - $this->_output->plain(''); - - if ($unit->run($options)) { - $this->_output->warn('Aborting due to unit test errors.'); - return; - } - - $this->_output->ok('No problems found in unit test.'); + /** + * Catch predefined release pipelines + * Otherwise, revert to traditional behaviour + */ + $arguments = $this->_config->getArguments(); + if ((count($arguments) == 3) && + $arguments[0] == 'release' && + $arguments[1] == 'for') { + $pipeline = $arguments[2]; + if (empty($options['pipeline']['release'][$pipeline])) { + $this->_output->warn("Pipeline $pipeline not defined in config"); + return; } + $this->_release->run( + ['pipeline:', $pipeline], + $component, + $options + ); + return; + } + + if ($this->_doTask('unittest')) { + $sequence[] = 'Unit'; $pre_commit = true; }