├── .gitignore ├── CONTRIBUTING.md ├── tests ├── bootstrap.php ├── Api │ ├── GitlabTest.php │ └── GitHubTest.php ├── StdlogTest.php ├── FsioTest.php ├── ProducerContainerTest.php ├── Repo │ └── RepoTest.php └── ConfigTest.php ├── src ├── Exception.php ├── Command │ ├── CommandInterface.php │ ├── Phpdoc.php │ ├── Release.php │ ├── Issues.php │ ├── Help.php │ ├── AbstractCommand.php │ └── Validate.php ├── Api │ ├── ApiInterface.php │ ├── Bitbucket.php │ ├── Github.php │ ├── Gitlab.php │ └── AbstractApi.php ├── Stdlog.php ├── Repo │ ├── RepoInterface.php │ ├── Git.php │ ├── Hg.php │ └── AbstractRepo.php ├── Http.php ├── Config.php ├── Fsio.php └── ProducerContainer.php ├── phpunit.xml.dist ├── TODO.md ├── LICENSE.md ├── bin └── producer ├── composer.json ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /vendor 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We are happy to review any contributions you want to make. When contributing, please follow the rules outlined at . 4 | 5 | The time between submitting a contribution and its review one may be extensive; do not be discouraged if there is not immediate feedback. 6 | 7 | Thanks! 8 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tests 5 | 6 | 7 | 8 | 9 | src 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Command/CommandInterface.php: -------------------------------------------------------------------------------- 1 | . 20 | 21 | ``` 22 | producer versions 23 | lists existing release versions 24 | 25 | producer log-since {$version} 26 | ``` 27 | -------------------------------------------------------------------------------- /src/Command/Phpdoc.php: -------------------------------------------------------------------------------- 1 | repo->checkDocblocks(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Command/Release.php: -------------------------------------------------------------------------------- 1 | logger->warning("THIS WILL RELEASE THE PACKAGE."); 32 | parent::__invoke($argv); 33 | $this->logger->info("Releasing $this->package $this->version"); 34 | $this->api->release($this->repo, $this->version); 35 | $this->logger->info("Released $this->package $this->version !"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Api/ApiInterface.php: -------------------------------------------------------------------------------- 1 | assertEquals($repoName, $api->getRepoName()); 20 | } 21 | 22 | public function remoteProvider() 23 | { 24 | return [ 25 | ['git@gitlab.com:user/repo.git', 'gitlab.com', 'user/repo'], 26 | ['http://gitlab.com/user/repo.git', 'gitlab.com', 'user/repo'], 27 | ['https://gitlab.com/user/repo.git', 'gitlab.com', 'user/repo'], 28 | ['git@example.org:user/repo.git', 'example.org', 'user/repo'], 29 | ['http://example.org/user/repo.git', 'example.org', 'user/repo'], 30 | ['https://example.org/user/repo.git', 'example.org', 'user/repo'], 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Api/GitHubTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($repoName, $api->getRepoName()); 21 | } 22 | 23 | public function remoteProvider() 24 | { 25 | return [ 26 | ['git@github.com:user/repo.git', 'api.github.com', 'user/repo'], 27 | ['http://github.com/user/repo.git', 'api.github.com', 'user/repo'], 28 | ['https://github.com/user/repo.git', 'api.github.com', 'user/repo'], 29 | ['git@example.org:user/repo.git', 'example.org', 'user/repo'], 30 | ['http://example.org/user/repo.git', 'example.org', 'user/repo'], 31 | ['https://example.org/user/repo.git', 'example.org', 'user/repo'], 32 | ]; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2018, Producer for PHP 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Command/Issues.php: -------------------------------------------------------------------------------- 1 | api->issues(); 36 | if (empty($issues)) { 37 | return; 38 | } 39 | 40 | $this->logger->info($this->api->getRepoName()); 41 | $this->logger->info(''); 42 | foreach ($issues as $issue) { 43 | $this->logger->info(" {$issue->number}. {$issue->title}"); 44 | $this->logger->info(" {$issue->url}"); 45 | $this->logger->info(''); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /bin/producer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | newCommand($name); 48 | $exit = (int) $command($argv); 49 | exit($exit); 50 | } catch (\Exception $e) { 51 | echo $e->getMessage() . PHP_EOL; 52 | exit(1); 53 | } 54 | -------------------------------------------------------------------------------- /tests/StdlogTest.php: -------------------------------------------------------------------------------- 1 | stdout = fopen('php://memory', 'rw+'); 11 | $this->stderr = fopen('php://memory', 'rw+'); 12 | $this->logger = new Stdlog($this->stdout, $this->stderr); 13 | } 14 | 15 | public function testLog_stdout() 16 | { 17 | $this->logger->log(LogLevel::INFO, 'Foo {bar}', ['bar' => 'baz']); 18 | $this->assertLogged('Foo baz' . PHP_EOL, $this->stdout); 19 | $this->assertLogged('', $this->stderr); 20 | } 21 | 22 | public function testLog_stderr() 23 | { 24 | $this->logger->log(LogLevel::ERROR, 'Foo {bar}', ['bar' => 'baz']); 25 | $this->assertLogged('', $this->stdout); 26 | $this->assertLogged('Foo baz' . PHP_EOL, $this->stderr); 27 | } 28 | 29 | protected function assertLogged($expect, $handle) 30 | { 31 | rewind($handle); 32 | $actual = ''; 33 | while ($read = fread($handle, 8192)) { 34 | $actual .= $read; 35 | } 36 | 37 | $this->assertSame($expect, $actual); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "producer/producer", 3 | "type": "library", 4 | "description": "Tools for releasing library packages; supports Git, Mercurial, Github, Gitlab, and Bitbucket.", 5 | "keywords": [ 6 | "library", 7 | "package", 8 | "git", 9 | "hg", 10 | "mercurial", 11 | "github", 12 | "gitlab", 13 | "bitbucket" 14 | ], 15 | "homepage": "https://github.com/producerphp/producer.producer", 16 | "license": "MIT", 17 | "authors": [ 18 | { 19 | "name": "Producer Contributors", 20 | "homepage": "https://github.com/producerphp/producer.producer/contributors" 21 | } 22 | ], 23 | "require": { 24 | "php": ">=5.6.0", 25 | "psr/log": "~1.0" 26 | }, 27 | "suggest": { 28 | "phpunit/phpunit": "Producer will call `phpunit` from your Composer package or global system.", 29 | "phpdocumentor/phpdocumentor": "Producer will call `phpdoc` from your Composer package or global system." 30 | }, 31 | "require-dev": { 32 | "phpunit/phpunit": "~5.0", 33 | "phpdocumentor/phpdocumentor": "~2.0", 34 | "pds/skeleton": "~1.0" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "Producer\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "Producer\\": "tests/" 44 | } 45 | }, 46 | "bin": [ 47 | "bin/producer" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/Command/Help.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 41 | } 42 | 43 | /** 44 | * 45 | * The command logic. 46 | * 47 | * @param array $argv Command line arguments. 48 | * 49 | * @return mixed 50 | * 51 | */ 52 | public function __invoke(array $argv) 53 | { 54 | $this->logger->info('Producer: a tool for releasing library packages.'); 55 | $this->logger->info('Available commands:'); 56 | $this->logger->info(' issues -- Show open issues from the remote origin.'); 57 | $this->logger->info(' phpdoc -- Validate the PHP docblocks in the src directory.'); 58 | $this->logger->info(' validate -- Validate the repository for a release.'); 59 | $this->logger->info(' release -- Release the repository as .'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2.3.0 4 | 5 | - When releasing a package with a CHANGELOG, only the most-recent block of 6 | notes (indicated by the first `## version` heading) is reported to the 7 | repository. (If the file is not named CHANGELOG, or if no `## version` block 8 | is found, the whole text is still sent.) 9 | 10 | ## 2.2.0 11 | 12 | - Added `--no-docs` option to suppress running PHPDocumentor; this is for 13 | projects release versions using PHP 7.1 nullable types, which PHPDocumentor 14 | does not support yet. 15 | 16 | - Renamed CHANGES.md to CHANGELOG.md; now compliant with `pds/skeleton`. 17 | 18 | ## 2.1.0 19 | 20 | - Add support for GitHub Enterprise, self-hosted GitLab, and Bitbucket Server 21 | via `*_hostname` config directives. 22 | 23 | - The CHANGES file is now checked for existence *last*, so that those without 24 | a CHANGES file can update it once at the very end of the validation process. 25 | 26 | - Added a README note that Producer supports testing systems other than PHPUnit. 27 | 28 | ## 2.0.0 29 | 30 | Second major release. 31 | 32 | - Supports package-level installation (in addition to global installation). 33 | 34 | - Supports package-specific configuration file at `.producer/config`, allowing you to specify the `@package` name in docblocks, the `phpunit` and `phpdoc` command paths, and the names of the various support files. 35 | 36 | - No longer installs `phpunit` and `phpdoc`; you will need to install them yourself, either globally or as part of your package. 37 | 38 | - Reorganized internals to split out HTTP interactions. 39 | 40 | - Updated instructions and tests. 41 | 42 | ## 1.0.0 43 | 44 | First major release. 45 | -------------------------------------------------------------------------------- /tests/FsioTest.php: -------------------------------------------------------------------------------- 1 | fsio = new Fsio(__DIR__); 12 | } 13 | 14 | public function testIsDir() 15 | { 16 | $this->assertTrue($this->fsio->isDir('../' . basename(__DIR__))); 17 | } 18 | 19 | public function testMkdir() 20 | { 21 | $dir = 'tmp'; 22 | $this->fsio->rmdir($dir); 23 | 24 | $this->assertFalse($this->fsio->isDir($dir)); 25 | $this->fsio->mkdir($dir); 26 | $this->assertTrue($this->fsio->isDir($dir)); 27 | $this->fsio->rmdir($dir); 28 | $this->assertFalse($this->fsio->isDir($dir)); 29 | 30 | $this->setExpectedException( 31 | 'Producer\Exception', 32 | 'mkdir(): File exists' 33 | ); 34 | $this->fsio->mkdir('../' . basename(__DIR__)); 35 | } 36 | 37 | public function testPutAndGet() 38 | { 39 | $file = 'fakefile'; 40 | $this->fsio->unlink($file); 41 | 42 | $expect = 'fake text'; 43 | $this->fsio->put($file, $expect); 44 | $actual = $this->fsio->get($file); 45 | $this->assertSame($expect, $actual); 46 | $this->fsio->unlink($file); 47 | } 48 | 49 | public function testPut_error() 50 | { 51 | $this->setExpectedException( 52 | 'Producer\Exception', 53 | 'No such file or directory' 54 | ); 55 | $this->fsio->put('no-such-directory/fakefile', 'fake text'); 56 | } 57 | 58 | public function testGet_error() 59 | { 60 | $this->setExpectedException( 61 | 'Producer\Exception', 62 | 'No such file or directory' 63 | ); 64 | $this->fsio->get('no-such-directory/fakefile'); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Command/AbstractCommand.php: -------------------------------------------------------------------------------- 1 | logger = $logger; 81 | $this->repo = $repo; 82 | $this->api = $api; 83 | $this->config = $config; 84 | } 85 | 86 | /** 87 | * 88 | * The command logic. 89 | * 90 | * @param array $argv Command line arguments. 91 | * 92 | * @return mixed 93 | * 94 | */ 95 | abstract public function __invoke(array $argv); 96 | } 97 | -------------------------------------------------------------------------------- /tests/ProducerContainerTest.php: -------------------------------------------------------------------------------- 1 | getMethod('newApi'); 21 | $newApi->setAccessible(true); 22 | 23 | $config = $this->mockConfig([ 24 | 'github_hostname' => $host, 25 | 'github_username' => 'producer', 26 | 'github_token' => 'token', 27 | ]); 28 | 29 | // $container->newApi($origin, $config) : ApiInterfaces 30 | $api = $newApi->invokeArgs($container, [$origin, $config]); 31 | 32 | $this->assertInstanceOf(Github::class, $api); 33 | $this->assertEquals($repoName, $api->getRepoName()); 34 | } 35 | 36 | public function githubProvider() 37 | { 38 | return [ 39 | ['github.enterprise.com', 'git@github.enterprise.com:producer/producer.git', 'producer/producer'], 40 | ['api.github.com', 'git@github.com:producer/producer.git', 'producer/producer'], 41 | ]; 42 | } 43 | 44 | /** 45 | * @return \PHPUnit_Framework_MockObject_MockObject 46 | */ 47 | protected function mockConfig($data) 48 | { 49 | $config = $this->getMockBuilder(Config::class) 50 | ->disableOriginalConstructor() 51 | ->getMock(); 52 | 53 | foreach ($data as $arg => $return) { 54 | $valueMap[] = [$arg, $return]; 55 | } 56 | 57 | $config->expects($this->any()) 58 | ->method('get') 59 | ->will($this->returnValueMap($valueMap)); 60 | 61 | return $config; 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/Stdlog.php: -------------------------------------------------------------------------------- 1 | stdout = $stdout; 67 | $this->stderr = $stderr; 68 | } 69 | 70 | /** 71 | * 72 | * Logs with an arbitrary level. 73 | * 74 | * @param mixed $level 75 | * 76 | * @param string $message 77 | * 78 | * @param array $context 79 | * 80 | * @return null 81 | * 82 | */ 83 | public function log($level, $message, array $context = []) 84 | { 85 | $replace = []; 86 | foreach ($context as $key => $val) { 87 | $replace['{' . $key . '}'] = $val; 88 | } 89 | $message = strtr($message, $replace) . PHP_EOL; 90 | 91 | $handle = $this->stdout; 92 | if (in_array($level, $this->stderrLevels)) { 93 | $handle = $this->stderr; 94 | } 95 | 96 | fwrite($handle, $message); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Repo/RepoInterface.php: -------------------------------------------------------------------------------- 1 | setHttp("https://{$user}:{$pass}@{$hostname}/2.0"); 39 | $this->setRepoNameFromOrigin($origin); 40 | } 41 | 42 | /** 43 | * 44 | * Sets the repo name based on the origin. 45 | * 46 | * @param string $origin The repo origin. 47 | * 48 | */ 49 | protected function setRepoNameFromOrigin($origin) 50 | { 51 | $repoName = parse_url($origin, PHP_URL_PATH); 52 | $repoName = preg_replace('/\.hg$/', '', $repoName); 53 | $this->repoName = trim($repoName, '/'); 54 | } 55 | 56 | /** 57 | * 58 | * Extracts the value elements from the API JSON result. 59 | * 60 | * @param mixed $json The API JSON result. 61 | * 62 | * @return mixed 63 | * 64 | */ 65 | protected function httpValues($json) 66 | { 67 | return $json->values; 68 | } 69 | 70 | /** 71 | * 72 | * Returns a list of open issues from the API. 73 | * 74 | * @return array 75 | * 76 | */ 77 | public function issues() 78 | { 79 | $issues = []; 80 | 81 | $yield = $this->httpGet( 82 | "/repositories/{$this->repoName}/issues", 83 | [ 84 | 'sort' => 'created_on' 85 | ] 86 | ); 87 | 88 | foreach ($yield as $issue) { 89 | $issues[] = (object) [ 90 | 'title' => $issue->title, 91 | 'number' => $issue->id, 92 | 'url' => "https://bitbucket.org/{$this->repoName}/issues/{$issue->id}", 93 | ]; 94 | } 95 | 96 | return $issues; 97 | } 98 | 99 | /** 100 | * 101 | * Submits a release to the API. 102 | * 103 | * @param RepoInterface $repo The repository. 104 | * 105 | * @param string $version The version number to release. 106 | * 107 | */ 108 | public function release(RepoInterface $repo, $version) 109 | { 110 | $repo->tag($version, "Released $version"); 111 | $repo->sync(); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Api/Github.php: -------------------------------------------------------------------------------- 1 | setHttp("https://{$user}:{$token}@{$hostname}"); 43 | $this->setRepoNameFromOrigin($origin); 44 | } 45 | 46 | /** 47 | * 48 | * Returns a list of open issues from the API. 49 | * 50 | * @return array 51 | * 52 | */ 53 | public function issues() 54 | { 55 | $issues = []; 56 | 57 | $yield = $this->httpGet( 58 | "/repos/{$this->repoName}/issues", 59 | [ 60 | 'sort' => 'created', 61 | 'direction' => 'asc', 62 | ] 63 | ); 64 | 65 | foreach ($yield as $issue) { 66 | $issues[] = (object) [ 67 | 'title' => $issue->title, 68 | 'number' => $issue->number, 69 | 'url' => $issue->html_url, 70 | ]; 71 | } 72 | 73 | return $issues; 74 | } 75 | 76 | /** 77 | * 78 | * Submits a release to the API. 79 | * 80 | * @param RepoInterface $repo The repository. 81 | * 82 | * @param string $version The version number to release. 83 | * 84 | */ 85 | public function release(RepoInterface $repo, $version) 86 | { 87 | $prerelease = substr($version, 0, 2) == '0.' 88 | || strpos($version, 'dev') !== false 89 | || strpos($version, 'alpha') !== false 90 | || strpos($version, 'beta') !== false; 91 | 92 | $query = []; 93 | 94 | $data = [ 95 | 'tag_name' => $version, 96 | 'target_commitish' => $repo->getBranch(), 97 | 'name' => $version, 98 | 'body' => $repo->getChanges(), 99 | 'draft' => false, 100 | 'prerelease' => $prerelease, 101 | ]; 102 | 103 | $response = $this->httpPost( 104 | "/repos/{$this->repoName}/releases", 105 | $query, 106 | $data 107 | ); 108 | 109 | if (! isset($response->id)) { 110 | $message = var_export((array) $response, true); 111 | throw new Exception($message); 112 | } 113 | 114 | $repo->sync(); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Http.php: -------------------------------------------------------------------------------- 1 | base = $base; 39 | } 40 | 41 | /** 42 | * 43 | * Make an HTTP request. 44 | * 45 | * @param string $method The HTTP method. 46 | * 47 | * @param string $path Append this path to the base URL. 48 | * 49 | * @param array $query Query parameters. 50 | * 51 | * @param array $data Data to be JSON-encoded as the message body. 52 | * 53 | * @return mixed The HTTP response. 54 | * 55 | */ 56 | public function __invoke($method, $path, array $query = [], array $data = []) 57 | { 58 | $url = $this->base . $path; 59 | if ($query) { 60 | $url .= '?' . http_build_query($query); 61 | } 62 | 63 | $context = $this->newContext($method, $data); 64 | return json_decode(file_get_contents($url, FALSE, $context)); 65 | } 66 | 67 | /** 68 | * 69 | * Convenience method for HTTP GET. 70 | * 71 | * @param string $path Append this path to the base URL. 72 | * 73 | * @param array $query Query parameters. 74 | * 75 | * @return mixed The HTTP response. 76 | * 77 | */ 78 | public function get($path, array $query = []) 79 | { 80 | return $this('GET', $path, $query); 81 | } 82 | 83 | /** 84 | * 85 | * Convenience method for HTTP POST. 86 | * 87 | * @param string $path Append this path to the base URL. 88 | * 89 | * @param array $query Query parameters. 90 | * 91 | * @param array $data Data to be JSON-encoded as the message body. 92 | * 93 | * @return mixed The HTTP response. 94 | * 95 | */ 96 | public function post($path, array $query = [], array $data = []) 97 | { 98 | return $this('POST', $path, $query, $data); 99 | } 100 | 101 | /** 102 | * 103 | * Creates a new stream context. 104 | * 105 | * @param string $method The HTTP method. 106 | * 107 | * @param array $data Data to be JSON-encoded as the message body. 108 | * 109 | * @return resource 110 | * 111 | */ 112 | protected function newContext($method, array $data = []) 113 | { 114 | $http = [ 115 | 'method' => $method, 116 | 'header' => implode("\r\n", [ 117 | 'User-Agent: php/stream', 118 | 'Accept: application/json', 119 | 'Content-Type: application/json', 120 | ]), 121 | ]; 122 | 123 | if ($data) { 124 | $http['content'] = json_encode($data); 125 | } 126 | 127 | return stream_context_create(['http' => $http, 'https' => $http]); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Api/Gitlab.php: -------------------------------------------------------------------------------- 1 | setHttp("https://{$hostname}/api/v3"); 53 | $this->token = $token; 54 | $this->hostname = $hostname; 55 | $this->setRepoNameFromOrigin($origin); 56 | } 57 | 58 | /** 59 | * 60 | * Modifies query params to add a page and other API-specific params. 61 | * 62 | * @param array $query The query params. 63 | * 64 | * @param int $page The page number, if any. 65 | * 66 | * @return array 67 | * 68 | */ 69 | protected function httpQuery(array $query, $page = 0) 70 | { 71 | $query['private_token'] = $this->token; 72 | return parent::httpQuery($query, $page); 73 | } 74 | 75 | /** 76 | * 77 | * Returns a list of open issues from the API. 78 | * 79 | * @return array 80 | * 81 | */ 82 | public function issues() 83 | { 84 | $issues = []; 85 | 86 | $repoName = urlencode($this->repoName); 87 | $yield = $this->httpGet( 88 | "/projects/{$repoName}/issues", 89 | [ 90 | 'sort' => 'asc', 91 | ] 92 | ); 93 | 94 | foreach ($yield as $issue) { 95 | $issues[] = (object) [ 96 | 'number' => $issue->iid, 97 | 'title' => $issue->title, 98 | 'url' => "https://{$this->hostname}/{$this->repoName}/issues/{$issue->iid}", 99 | ]; 100 | } 101 | 102 | return $issues; 103 | } 104 | 105 | /** 106 | * 107 | * Submits a release to the API. 108 | * 109 | * @param RepoInterface $repo The repository. 110 | * 111 | * @param string $version The version number to release. 112 | * 113 | */ 114 | public function release(RepoInterface $repo, $version) 115 | { 116 | $query = []; 117 | 118 | $data = [ 119 | 'id' => $this->repoName, 120 | 'tag_name' => $version, 121 | 'ref' => $repo->getBranch(), 122 | 'release_description' => $repo->getChanges() 123 | ]; 124 | 125 | $repoName = urlencode($this->repoName); 126 | $response = $this->httpPost( 127 | "/projects/{$repoName}/repository/tags", 128 | $query, 129 | $data 130 | ); 131 | 132 | if (! isset($response->name)) { 133 | $message = var_export((array) $response, true); 134 | throw new Exception($message); 135 | } 136 | 137 | $repo->sync(); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/Repo/RepoTest.php: -------------------------------------------------------------------------------- 1 | origin = 'FAKE'; 13 | } 14 | 15 | public function getBranch() 16 | { 17 | 18 | } 19 | 20 | public function checkStatus() 21 | { 22 | 23 | } 24 | 25 | public function tag($name, $message) 26 | { 27 | 28 | } 29 | 30 | public function sync() 31 | { 32 | 33 | } 34 | } 35 | 36 | class RepoTest extends \PHPUnit\Framework\TestCase 37 | { 38 | protected function mockFsio($text) 39 | { 40 | $fsio = $this->getMockBuilder(Fsio::class) 41 | ->disableOriginalConstructor() 42 | ->setMethods(['get']) 43 | ->getMock(); 44 | 45 | $fsio->expects($this->any()) 46 | ->method('get') 47 | ->will($this->returnValue($text)); 48 | 49 | return $fsio; 50 | } 51 | 52 | protected function mockConfig(array $files) 53 | { 54 | $config = $this->getMockBuilder(Config::class) 55 | ->disableOriginalConstructor() 56 | ->setMethods(['get']) 57 | ->getMock(); 58 | 59 | $config->expects($this->any()) 60 | ->method('get') 61 | ->will($this->returnValue($files)); 62 | 63 | return $config; 64 | } 65 | 66 | public function testGetChanges() 67 | { 68 | $fsio = $this->mockFsio($this->changelog); 69 | $logger = new Stdlog(STDOUT, STDERR); 70 | $config = $this->mockConfig([ 71 | 'changes' => 'CHANGES.md', 72 | ]); 73 | $repo = new FakeRepo($fsio, $logger, $config); 74 | $actual = $repo->getChanges(); 75 | $expect = $this->changelog; 76 | $this->assertSame($expect, $actual); 77 | 78 | $config = $this->mockConfig([ 79 | 'changes' => 'CHANGELOG.md', 80 | ]); 81 | $repo = new FakeRepo($fsio, $logger, $config); 82 | $actual = $repo->getChanges(); 83 | $expect = trim($this->subset); 84 | $this->assertSame($expect, $actual); 85 | } 86 | 87 | protected $subset = " 88 | - Added `--no-docs` option to suppress running PHPDocumentor; this is for 89 | projects release versions using PHP 7.1 nullable types, which PHPDocumentor 90 | does not support yet. 91 | 92 | - Renamed CHANGES.md to CHANGELOG.md; now compliant with `pds/skeleton`. 93 | "; 94 | 95 | protected $changelog = "# CHANGELOG 96 | 97 | ## 2.2.0 98 | 99 | - Added `--no-docs` option to suppress running PHPDocumentor; this is for 100 | projects release versions using PHP 7.1 nullable types, which PHPDocumentor 101 | does not support yet. 102 | 103 | - Renamed CHANGES.md to CHANGELOG.md; now compliant with `pds/skeleton`. 104 | 105 | ## 2.1.0 106 | 107 | - Add support for GitHub Enterprise, self-hosted GitLab, and Bitbucket Server 108 | via `*_hostname` config directives. 109 | 110 | - The CHANGES file is now checked for existence *last*, so that those without 111 | a CHANGES file can update it once at the very end of the validation process. 112 | 113 | - Added a README note that Producer supports testing systems other than PHPUnit. 114 | 115 | ## 2.0.0 116 | 117 | Second major release. 118 | 119 | - Supports package-level installation (in addition to global installation). 120 | 121 | - Supports package-specific configuration file at `.producer/config`, allowing you to specify the `@package` name in docblocks, the `phpunit` and `phpdoc` command paths, and the names of the various support files. 122 | 123 | - No longer installs `phpunit` and `phpdoc`; you will need to install them yourself, either globally or as part of your package. 124 | 125 | - Reorganized internals to split out HTTP interactions. 126 | 127 | - Updated instructions and tests. 128 | 129 | ## 1.0.0 130 | 131 | First major release. 132 | 133 | "; 134 | } 135 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | 'api.bitbucket.org', 29 | 'bitbucket_username' => null, 30 | 'bitbucket_password' => null, 31 | 'github_hostname' => 'api.github.com', 32 | 'github_username' => null, 33 | 'github_token' => null, 34 | 'gitlab_hostname' => 'gitlab.com', 35 | 'gitlab_token' => null, 36 | 'package' => '', 37 | 'commands' => [ 38 | 'phpdoc' => 'phpdoc', 39 | 'phpunit' => 'phpunit', 40 | ], 41 | 'files' => [ 42 | 'changes' => 'CHANGES.md', 43 | 'contributing' => 'CONTRIBUTING.md', 44 | 'license' => 'LICENSE.md', 45 | 'phpunit' => 'phpunit.xml.dist', 46 | 'readme' => 'README.md', 47 | ], 48 | ]; 49 | 50 | /** 51 | * 52 | * The name of the Producer config file, wherever located. 53 | * 54 | * @var string 55 | * 56 | */ 57 | protected $configFile = '.producer/config'; 58 | 59 | /** 60 | * 61 | * Constructor. 62 | * 63 | * @param Fsio $homefs The user's home directory filesystem. 64 | * 65 | * @param Fsio $repofs The package repository filesystem. 66 | * 67 | * @throws Exception 68 | * 69 | */ 70 | public function __construct(Fsio $homefs, Fsio $repofs) 71 | { 72 | $this->loadHomeConfig($homefs); 73 | $this->loadRepoConfig($repofs); 74 | } 75 | 76 | /** 77 | * 78 | * Loads the user's home directory Producer config file. 79 | * 80 | * @param Fsio $homefs 81 | * 82 | * @throws Exception 83 | * 84 | */ 85 | protected function loadHomeConfig(Fsio $homefs) 86 | { 87 | if (! $homefs->isFile($this->configFile)) { 88 | $path = $homefs->path($this->configFile); 89 | throw new Exception("Config file {$path} not found."); 90 | } 91 | 92 | $config = $homefs->parseIni($this->configFile, true); 93 | $this->data = array_replace_recursive($this->data, $config); 94 | } 95 | 96 | /** 97 | * 98 | * Loads the project's config file, if it exists. 99 | * 100 | * @param Fsio $fsio 101 | * 102 | * @throws Exception 103 | * 104 | */ 105 | public function loadRepoConfig(Fsio $repofs) 106 | { 107 | if (! $repofs->isFile($this->configFile)) { 108 | return; 109 | } 110 | 111 | $config = $repofs->parseIni($this->configFile, true); 112 | $this->data = array_replace_recursive($this->data, $config); 113 | } 114 | 115 | /** 116 | * 117 | * Returns a config value. 118 | * 119 | * @param string $key The config value. 120 | * 121 | * @return mixed 122 | * 123 | */ 124 | public function get($key) 125 | { 126 | if (isset($this->data[$key])) { 127 | return $this->data[$key]; 128 | } 129 | 130 | throw new Exception("No config value set for '$key'."); 131 | } 132 | 133 | /** 134 | * 135 | * Confirm that a config value is set 136 | * 137 | * @param $key 138 | * 139 | * @return bool 140 | * 141 | */ 142 | public function has($key) { 143 | return (isset($this->data[$key])); 144 | } 145 | 146 | /** 147 | * 148 | * Return all configuration data 149 | * 150 | * @return array 151 | * 152 | */ 153 | public function getAll() 154 | { 155 | return $this->data; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Api/AbstractApi.php: -------------------------------------------------------------------------------- 1 | repoName; 51 | } 52 | 53 | /** 54 | * 55 | * Sets a new HTTP object. 56 | * 57 | * @param string $base The base URL for HTTP calls. 58 | * 59 | */ 60 | protected function setHttp($base) 61 | { 62 | $this->http = new Http($base); 63 | } 64 | 65 | /** 66 | * 67 | * Sets the repo name based on the origin. 68 | * 69 | * @param string $origin The repo origin. 70 | * 71 | */ 72 | protected function setRepoNameFromOrigin($origin) 73 | { 74 | // if ssh, strip username off so `parse_url` can work as expected 75 | if (strpos($origin, 'git@') !== false) { 76 | $origin = substr($origin, 4); 77 | } 78 | 79 | // get path from url, strip .git from the end, and retain 80 | $repoName = parse_url($origin, PHP_URL_PATH); 81 | $repoName = preg_replace('/\.git$/', '', $repoName); 82 | $this->repoName = trim($repoName, '/'); 83 | } 84 | 85 | /** 86 | * 87 | * Repeats an HTTP GET to get all results from all pages. 88 | * 89 | * @param string $path GET from this path. 90 | * 91 | * @param array $query Query params. 92 | * 93 | * @return \Generator 94 | * 95 | */ 96 | protected function httpGet($path, array $query = []) 97 | { 98 | $page = 1; 99 | do { 100 | $found = false; 101 | $query['page'] = $page; 102 | $query = $this->httpQuery($query); 103 | $json = $this->http->get($path, $query); 104 | foreach ($this->httpValues($json) as $item) { 105 | $found = true; 106 | yield $item; 107 | } 108 | $page ++; 109 | } while ($found); 110 | } 111 | 112 | /** 113 | * 114 | * Makes one HTTP POST call and returns the results. 115 | * 116 | * @param string $path POST to this path. 117 | * 118 | * @param array $query Query params. 119 | * 120 | * @param array $data Data to be JSON-encoded as the HTTP message body. 121 | * 122 | * @return mixed 123 | * 124 | */ 125 | protected function httpPost($path, array $query = [], array $data = []) 126 | { 127 | $query = $this->httpQuery($query); 128 | return $this->httpValues($this->http->post($path, $query, $data)); 129 | } 130 | 131 | /** 132 | * 133 | * Modifies query params to add a page and other API-specific params. 134 | * 135 | * @param array $query The query params. 136 | * 137 | * @param int $page The page number, if any. 138 | * 139 | * @return array 140 | * 141 | */ 142 | protected function httpQuery(array $query, $page = 0) 143 | { 144 | if ($page) { 145 | $query['page'] = $page; 146 | } 147 | return $query; 148 | } 149 | 150 | /** 151 | * 152 | * Extracts the value elements from the API JSON result. 153 | * 154 | * @param mixed $json The API JSON result. 155 | * 156 | * @return mixed 157 | * 158 | */ 159 | protected function httpValues($json) 160 | { 161 | return $json; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Command/Validate.php: -------------------------------------------------------------------------------- 1 | true 52 | ]; 53 | 54 | /** 55 | * 56 | * The command logic. 57 | * 58 | * @param array $argv Command line arguments. 59 | * 60 | * @return mixed 61 | * 62 | */ 63 | public function __invoke(array $argv) 64 | { 65 | $this->setOptions($argv); 66 | $this->setVersion(array_shift($argv)); 67 | 68 | $this->repo->sync(); 69 | $this->repo->validateComposer(); 70 | 71 | $this->package = $this->repo->getPackage(); 72 | $this->logger->info("Validating {$this->package} {$this->version}"); 73 | 74 | $this->repo->checkSupportFiles(); 75 | $this->repo->checkLicenseYear(); 76 | $this->repo->checkTests(); 77 | 78 | if ($this->options['checkDocblocks']) { 79 | $this->repo->checkDocblocks($this->version); 80 | } else { 81 | $this->logger->info("Not checking docblocks."); 82 | } 83 | 84 | $this->repo->checkChanges(); 85 | $this->checkIssues(); 86 | $this->logger->info("{$this->package} {$this->version} appears valid for release!"); 87 | } 88 | 89 | /** 90 | * 91 | * Extracts options from the command-line argument values. 92 | * 93 | * @param array &$argv The argument value.s 94 | * 95 | */ 96 | protected function setOptions(array &$argv) 97 | { 98 | $key = array_search('--no-docs', $argv); 99 | if ($key !== false) { 100 | $this->options['checkDocblocks'] = false; 101 | unset($argv[$key]); 102 | } 103 | } 104 | 105 | /** 106 | * 107 | * Sets the version from a command-line argument. 108 | * 109 | * @param string $version The command-line argument. 110 | * 111 | */ 112 | protected function setVersion($version) 113 | { 114 | if (! $version) { 115 | throw new Exception('Please specify a version number.'); 116 | } 117 | 118 | if ($this->isValidVersion($version)) { 119 | $this->version = $version; 120 | return; 121 | } 122 | 123 | $message = "Please use the version format 1.2.3 or v1.2.3, optionally followed by -(dev|alpha|beta|RC|p), optionally followed by a number."; 124 | throw new Exception($message); 125 | } 126 | 127 | /** 128 | * 129 | * Is the version number valid? 130 | * 131 | * @param string $version The version number. 132 | * 133 | * @return bool 134 | * 135 | */ 136 | protected function isValidVersion($version) 137 | { 138 | $format = '^(v?\d+.\d+.\d+)(-(dev|alpha|beta|RC|p)\d*)?$'; 139 | preg_match("/$format/", $version, $matches); 140 | return (bool) $matches; 141 | } 142 | 143 | /** 144 | * 145 | * Checks to see if there are open issues. 146 | * 147 | */ 148 | protected function checkIssues() 149 | { 150 | $issues = $this->api->issues(); 151 | if (empty($issues)) { 152 | $this->logger->info('No open issues.'); 153 | return; 154 | } 155 | 156 | $this->logger->warning('There are open issues:'); 157 | foreach ($issues as $issue) { 158 | $this->logger->warning(" {$issue->number}. {$issue->title}"); 159 | $this->logger->warning(" {$issue->url}"); 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/Repo/Git.php: -------------------------------------------------------------------------------- 1 | fsio->parseIni('.git/config', true); 30 | 31 | if (! isset($data['remote origin']['url'])) { 32 | throw new Exception('Could not determine remote origin.'); 33 | } 34 | 35 | $this->origin = $data['remote origin']['url']; 36 | } 37 | 38 | /** 39 | * 40 | * Returns the current branch. 41 | * 42 | * @return string 43 | * 44 | */ 45 | public function getBranch() 46 | { 47 | $branch = $this->shell('git rev-parse --abbrev-ref HEAD', $output, $return); 48 | if ($return) { 49 | throw new Exception(implode(PHP_EOL, $output), $return); 50 | } 51 | return trim($branch); 52 | } 53 | 54 | /** 55 | * 56 | * Syncs the repository with the origin: pull, push, and status. 57 | * 58 | */ 59 | public function sync() 60 | { 61 | $this->shell('git pull', $output, $return); 62 | if ($return) { 63 | throw new Exception('Pull failed.'); 64 | } 65 | 66 | $this->shell('git push', $output, $return); 67 | if ($return) { 68 | throw new Exception('Push failed.'); 69 | } 70 | 71 | $this->checkStatus(); 72 | } 73 | 74 | /** 75 | * 76 | * Checks that the local status is clean. 77 | * 78 | */ 79 | public function checkStatus() 80 | { 81 | $this->shell('git status --porcelain', $output, $return); 82 | if ($return || $output) { 83 | throw new Exception('Status failed.'); 84 | } 85 | } 86 | 87 | /** 88 | * 89 | * Gets the last-committed date of the CHANGES file. 90 | * 91 | * @return string 92 | * 93 | */ 94 | public function getChangesDate() 95 | { 96 | $changes = $this->config->get('files')['changes']; 97 | if (! $this->fsio->isFile($changes)) { 98 | throw new Exception("File '{$changes}' is missing."); 99 | } 100 | 101 | $this->shell("git log -1 {$changes}", $output, $return); 102 | return $this->findDate($output); 103 | } 104 | 105 | /** 106 | * 107 | * Gets the last-committed date of the repository. 108 | * 109 | * @return string 110 | * 111 | */ 112 | public function getLastCommitDate() 113 | { 114 | $this->shell("git log -1", $output, $return); 115 | return $this->findDate($output); 116 | } 117 | 118 | /** 119 | * 120 | * Finds the Date: line within an array of lines. 121 | * 122 | * @param array $lines An array of lines. 123 | * 124 | * @return string 125 | * 126 | */ 127 | protected function findDate(array $lines) 128 | { 129 | foreach ($lines as $line) { 130 | if (substr($line, 0, 5) == 'Date:') { 131 | return trim(substr($line, 5)); 132 | } 133 | } 134 | 135 | throw new Exception("No 'Date:' line found."); 136 | } 137 | 138 | /** 139 | * 140 | * Returns the log since a particular date, in chronological order. 141 | * 142 | * @param string $date Return log entries since this date. 143 | * 144 | * @return array 145 | * 146 | */ 147 | public function logSinceDate($date) 148 | { 149 | $this->shell("git log --name-only --since='$date' --reverse", $output); 150 | return $output; 151 | } 152 | 153 | /** 154 | * 155 | * Tags the repository. 156 | * 157 | * @param string $name The tag name. 158 | * 159 | * @param string $message The message for the tag. 160 | * 161 | */ 162 | public function tag($name, $message) 163 | { 164 | $message = escapeshellarg($message); 165 | $last = $this->shell("git tag -a $name --message=$message", $output, $return); 166 | if ($return) { 167 | throw new Exception($last); 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Repo/Hg.php: -------------------------------------------------------------------------------- 1 | fsio->parseIni('.hg/hgrc', true); 32 | 33 | if (! isset($data['paths']['default'])) { 34 | throw new Exception('Could not determine default path.'); 35 | } 36 | 37 | $this->origin = $data['paths']['default']; 38 | } 39 | 40 | /** 41 | * 42 | * Returns the current branch. 43 | * 44 | * @return string 45 | * 46 | */ 47 | public function getBranch() 48 | { 49 | $branch = $this->shell('hg branch', $output, $return); 50 | if ($return) { 51 | throw new Exception(implode(PHP_EOL, $output), $return); 52 | } 53 | return trim($branch); 54 | } 55 | 56 | /** 57 | * 58 | * Syncs the repository with the origin: pull, push, and status. 59 | * 60 | */ 61 | public function sync() 62 | { 63 | $this->shell('hg pull -u', $output, $return); 64 | if ($return) { 65 | throw new Exception('Pull and update failed.'); 66 | } 67 | 68 | // this allows for "no error" (0) and "nothing to push" (1). 69 | // cf. http://stackoverflow.com/questions/18536926/ 70 | $this->shell('hg push --rev .', $output, $return); 71 | if ($return > 1) { 72 | throw new Exception('Push failed.'); 73 | } 74 | 75 | $this->checkStatus(); 76 | } 77 | 78 | /** 79 | * 80 | * Checks that the local status is clean. 81 | * 82 | */ 83 | public function checkStatus() 84 | { 85 | $this->shell('hg status', $output, $return); 86 | if ($return || $output) { 87 | throw new Exception('Status failed.'); 88 | } 89 | } 90 | 91 | /** 92 | * 93 | * Gets the last-committed date of the CHANGES file. 94 | * 95 | * @return string 96 | * 97 | */ 98 | public function getChangesDate() 99 | { 100 | $changes = $this->config->get('files')['changes']; 101 | if (! $this->fsio->isFile($changes)) { 102 | throw new Exception("File '{$changes}' is missing."); 103 | } 104 | 105 | $this->shell("hg log --limit 1 {$changes}", $output, $return); 106 | return $this->findDate($output); 107 | } 108 | 109 | /** 110 | * 111 | * Gets the last-committed date of the repository. 112 | * 113 | * @return string 114 | * 115 | */ 116 | public function getLastCommitDate() 117 | { 118 | $this->shell("hg log --limit 1", $output, $return); 119 | return $this->findDate($output); 120 | } 121 | 122 | /** 123 | * 124 | * Finds the date: line within an array of lines. 125 | * 126 | * @param array $lines An array of lines. 127 | * 128 | * @return string 129 | * 130 | */ 131 | protected function findDate(array $lines) 132 | { 133 | foreach ($lines as $line) { 134 | if (substr($line, 0, 5) == 'date:') { 135 | return trim(substr($line, 5)); 136 | } 137 | } 138 | 139 | throw new Exception("No 'date:' line found."); 140 | } 141 | 142 | /** 143 | * 144 | * Returns the log since a particular date, in chronological order. 145 | * 146 | * @param string $date Return log entries since this date. 147 | * 148 | * @return array 149 | * 150 | */ 151 | public function logSinceDate($date) 152 | { 153 | $this->shell("hg log --rev : --date '$date to now'", $output); 154 | return $output; 155 | } 156 | 157 | /** 158 | * 159 | * Tags the repository. 160 | * 161 | * @param string $name The tag name. 162 | * 163 | * @param string $message The message for the tag. 164 | * 165 | */ 166 | public function tag($name, $message) 167 | { 168 | $message = escapeshellarg($message); 169 | $last = $this->shell("hg tag $name --message=$message", $output, $return); 170 | if ($return) { 171 | throw new Exception($last); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /tests/ConfigTest.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(Fsio::class) 9 | ->disableOriginalConstructor() 10 | ->setMethods(['isFile', 'parseIni']) 11 | ->getMock(); 12 | $fsio 13 | ->expects($this->any()) 14 | ->method('isFile')->will($this->returnValue($isFile)); 15 | $fsio 16 | ->expects($this->any()) 17 | ->method('parseIni')->will($this->returnValue($returnData)); 18 | 19 | return $fsio; 20 | } 21 | 22 | public function testLoadHomeConfig() 23 | { 24 | $homefs = $this->mockFsio([ 25 | 'gitlab_token' => 'foobarbazdibzimgir', 26 | 'commands' => [ 27 | 'phpunit' => '/path/to/phpunit', 28 | ] 29 | ]); 30 | $repofs = $this->mockFsio([], false); 31 | 32 | $config = new Config($homefs, $repofs); 33 | 34 | $expect = [ 35 | 'bitbucket_hostname' => 'api.bitbucket.org', 36 | 'bitbucket_username' => null, 37 | 'bitbucket_password' => null, 38 | 'github_hostname' => 'api.github.com', 39 | 'github_username' => null, 40 | 'github_token' => null, 41 | 'gitlab_hostname' => 'gitlab.com', 42 | 'gitlab_token' => 'foobarbazdibzimgir', 43 | 'package' => '', 44 | 'commands' => [ 45 | 'phpdoc' => 'phpdoc', 46 | 'phpunit' => '/path/to/phpunit', 47 | ], 48 | 'files' => [ 49 | 'changes' => 'CHANGES.md', 50 | 'contributing' => 'CONTRIBUTING.md', 51 | 'license' => 'LICENSE.md', 52 | 'phpunit' => 'phpunit.xml.dist', 53 | 'readme' => 'README.md', 54 | ], 55 | ]; 56 | 57 | $actual = $config->getAll(); 58 | 59 | $this->assertSame($expect, $actual); 60 | } 61 | 62 | public function testGitHubHostOverride() 63 | { 64 | $homefs = $this->mockFsio([ 65 | 'github_hostname' => 'example.org', 66 | 'github_username' => 'foo', 67 | 'github_token' => 'bar', 68 | ]); 69 | $repofs = $this->mockFsio([], false); 70 | 71 | $config = new Config($homefs, $repofs); 72 | 73 | $expect = [ 74 | 'bitbucket_hostname' => 'api.bitbucket.org', 75 | 'bitbucket_username' => null, 76 | 'bitbucket_password' => null, 77 | 'github_hostname' => 'example.org', 78 | 'github_username' => 'foo', 79 | 'github_token' => 'bar', 80 | 'gitlab_hostname' => 'gitlab.com', 81 | 'gitlab_token' => null, 82 | 'package' => '', 83 | 'commands' => [ 84 | 'phpdoc' => 'phpdoc', 85 | 'phpunit' => 'phpunit', 86 | ], 87 | 'files' => [ 88 | 'changes' => 'CHANGES.md', 89 | 'contributing' => 'CONTRIBUTING.md', 90 | 'license' => 'LICENSE.md', 91 | 'phpunit' => 'phpunit.xml.dist', 92 | 'readme' => 'README.md', 93 | ], 94 | ]; 95 | 96 | $actual = $config->getAll(); 97 | 98 | $this->assertSame($expect, $actual); 99 | } 100 | 101 | public function testGitlabHostOverride() 102 | { 103 | $homefs = $this->mockFsio([ 104 | 'gitlab_hostname' => 'example.org', 105 | 'gitlab_token' => 'bar', 106 | ]); 107 | $repofs = $this->mockFsio([], false); 108 | 109 | $config = new Config($homefs, $repofs); 110 | 111 | $expect = [ 112 | 'bitbucket_hostname' => 'api.bitbucket.org', 113 | 'bitbucket_username' => null, 114 | 'bitbucket_password' => null, 115 | 'github_hostname' => 'api.github.com', 116 | 'github_username' => null, 117 | 'github_token' => null, 118 | 'gitlab_hostname' => 'example.org', 119 | 'gitlab_token' => 'bar', 120 | 'package' => '', 121 | 'commands' => [ 122 | 'phpdoc' => 'phpdoc', 123 | 'phpunit' => 'phpunit', 124 | ], 125 | 'files' => [ 126 | 'changes' => 'CHANGES.md', 127 | 'contributing' => 'CONTRIBUTING.md', 128 | 'license' => 'LICENSE.md', 129 | 'phpunit' => 'phpunit.xml.dist', 130 | 'readme' => 'README.md', 131 | ], 132 | ]; 133 | 134 | $actual = $config->getAll(); 135 | 136 | $this->assertSame($expect, $actual); 137 | } 138 | 139 | public function testLoadHomeAndRepoConfig() 140 | { 141 | $homefs = $this->mockFsio(['gitlab_token' => 'foobarbazdibzimgir']); 142 | $repofs = $this->mockFsio([ 143 | 'package' => 'Foo.Bar', 144 | 'commands' => [ 145 | 'phpunit' => './vendor/bin/phpunit' 146 | ], 147 | 'files' => [ 148 | 'contributing' => '.github/CONTRIBUTING' 149 | ], 150 | ]); 151 | 152 | $config = new Config($homefs, $repofs); 153 | 154 | $expect = [ 155 | 'bitbucket_hostname' => 'api.bitbucket.org', 156 | 'bitbucket_username' => null, 157 | 'bitbucket_password' => null, 158 | 'github_hostname' => 'api.github.com', 159 | 'github_username' => null, 160 | 'github_token' => null, 161 | 'gitlab_hostname' => 'gitlab.com', 162 | 'gitlab_token' => 'foobarbazdibzimgir', 163 | 'package' => 'Foo.Bar', 164 | 'commands' => [ 165 | 'phpdoc' => 'phpdoc', 166 | 'phpunit' => './vendor/bin/phpunit', 167 | ], 168 | 'files' => [ 169 | 'changes' => 'CHANGES.md', 170 | 'contributing' => '.github/CONTRIBUTING', 171 | 'license' => 'LICENSE.md', 172 | 'phpunit' => 'phpunit.xml.dist', 173 | 'readme' => 'README.md', 174 | ], 175 | ]; 176 | 177 | $actual = $config->getAll(); 178 | 179 | $this->assertSame($expect, $actual); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Producer 2 | 3 | Producer is a command-line quality-assurance tool to validate, and then release, 4 | your PHP library package. It supports Git and Mercurial for version control, as 5 | well as Github, Gitlab, and Bitbucket for remote origins (including self-hosted 6 | origins). 7 | 8 | ## Installing 9 | 10 | Producer works in concert with [Composer][], [PHPUnit][], and [PHPDocumentor][]. 11 | Please install them first, either as part of your global system, or as part of 12 | your package. 13 | 14 | [Composer]: https://getcomposer.org 15 | [PHPUnit]: https://packagist.org/packages/phpunit/phpunit 16 | [PHPDocumentor]: https://packagist.org/packages/phpdocumentor/phpdocumentor 17 | 18 | ### Global Install 19 | 20 | To install Producer globally, issue `composer global require producer/producer`. 21 | 22 | Be sure to add `$COMPOSER_HOME/vendor/bin` to your `$PATH`; 23 | [instuctions here](https://getcomposer.org/doc/03-cli.md#global). 24 | 25 | Test the installation by issuing `producer` at the command line to see some 26 | "help" output. 27 | 28 | > Remember, you will need [PHPUnit][] and [PHPDocumentor][] as well. 29 | 30 | ### Package Install 31 | 32 | To install the Producer package as a development requirement for your package issue `composer require --dev producer/producer`. 33 | 34 | Test the installation by issuing `./vendor/bin/producer` at the command line to 35 | see some "help" output. 36 | 37 | > Remember, you will need [PHPUnit][] and [PHPDocumentor][] as well. 38 | 39 | ## Configuring 40 | 41 | Before you get going, you'll need to create a `~/.producer/config` file. Copy 42 | and paste the following into your terminal: 43 | 44 | ``` 45 | mkdir ~/.producer 46 | 47 | echo "; Github 48 | github_username = 49 | github_token = 50 | 51 | ; Gitlab 52 | gitlab_token = 53 | 54 | ; Bitbucket 55 | bitbucket_username = 56 | bitbucket_password =" > ~/.producer/config 57 | ``` 58 | 59 | You can then edit `~/.producer/config` to enter your access credentials, any or 60 | all of: 61 | 62 | - [Github personal API token](https://github.com/settings/tokens), 63 | - [Gitlab private token](https://gitlab.com/profile/account), or 64 | - Bitbucket username and password. 65 | 66 | > WARNING: Saving your username and password for Bitbucket in plain text is not 67 | > very secure. Bitbucket doesn't have personal API tokens, so it's either 68 | > "username and password" or bring in an external OAuth1 library with all its 69 | > dependencies just to authenticate to Bitbucket. The latter option might show 70 | > up in a subsequent release. 71 | 72 | ### Package Configuration 73 | 74 | Inside your package repository, you may define a `.producer/config` file that 75 | sets any of the following options for that specific package. 76 | 77 | ```ini 78 | ; custom @package docblock value 79 | package = Custom.Name 80 | 81 | ; custom hostnames for self-hosted origins 82 | github_hostname = example.com 83 | gitlab_hostname = example.net 84 | bitbucket_hostname = example.org 85 | 86 | ; commands to use for phpunit and phpdoc 87 | [commands] 88 | phpunit = /path/to/phpunit 89 | phpdoc = /path/to/phpdoc 90 | 91 | ; names for support files 92 | [files] 93 | changes = CHANGES.md 94 | contributing = CONTRIBUTING.md 95 | license = LICENSE.md 96 | phpunit = phpunit.xml.dist 97 | readme = README.md 98 | ``` 99 | 100 | > **Testing Systems**: If you want to use a testing system other than PHPUnit, 101 | > you can set `phpunit = /whatever/you/want`. As long as it exits non-zero when 102 | > the tests fail, Producer will work with it properly. Yes, it was short-sighted 103 | > to name the key `phpunit`; a future release of Producer may remedy that. 104 | 105 | ## Getting Started 106 | 107 | Now that you have Producer installed and configured, change to the directory 108 | for your library package repository. From there, you can call the following 109 | commands: 110 | 111 | - `producer issues` will show all the open issues from the remote origin 112 | - `producer phpdoc` will check the PHP docblocks in the `src/` directory 113 | - `producer validate ` will validate the package for release, but won't 114 | actually release it 115 | - `producer release ` will validate, and then actually release, the 116 | package 117 | 118 | > NOTE: Producer reads the `.git` or `.hg` configuration data from the 119 | > repository, so it knows whether you are using Github, Gitlab, or Bitbucket 120 | > as the remote origin. 121 | 122 | ## Validating 123 | 124 | When you validate the library package, Producer will: 125 | 126 | - Sync with the remote origin (i.e., pull from the remote origin, then push any 127 | local changes, then check the local status to make sure everything is 128 | committed and pushed) 129 | - Validate the composer.json file 130 | - Check for informational files (see below) and for a `phpunit.xml.dist` file 131 | - Check that the license file has the current year in it 132 | - Call `composer update`, run the unit tests, and make sure they cleaned up after 133 | - Check that the PHP docblocks in the `src/` directory are valid (see below) 134 | - Check that the changes file is in the most-recent commit to the repository 135 | 136 | If any of those fails, then the package is not considered valid for release. 137 | 138 | In addition, the `validate` command will show any open issues from the remote 139 | origin, but these are presented only as a reminder, and will not be considered 140 | invalidators. 141 | 142 | ### Informational Files 143 | 144 | Producer wants you to have these informational files in the package root: 145 | 146 | - `CHANGES.md`, a list of changes for the release; 147 | - `CONTRIBUTING.md`, describing how to contribute to the library; 148 | - `LICENSE.md`, the package licensing text; and, 149 | - `README.md`, an introduction to the library. 150 | 151 | You may override these file names by setting the appropriate `.producer/config` 152 | directives. 153 | 154 | ### Docblocks 155 | 156 | Producer will not attempt to check docblocks for 0.*, -dev, or -alpha releases. 157 | It seems reasonable to expect that the codebase is not ready for documenting 158 | before a beta release. 159 | 160 | ## Releasing 161 | 162 | When you `release` the package, Producer will first `validate` it as a pre-flight step. 163 | 164 | Then it will use the Github or Gitlab API to create a release. In the case of 165 | Bitbucket (which does not have an API for releases) it will tag the repository 166 | locally. 167 | 168 | Finally, Producer will sync with the remote origin so that the release is 169 | represented locally, and/or pushed to the remote. 170 | -------------------------------------------------------------------------------- /src/Fsio.php: -------------------------------------------------------------------------------- 1 | root = $root; 45 | } 46 | 47 | /** 48 | * 49 | * Prefix the path to a file or directory with the root. 50 | * 51 | * @param string $spec The file or directory, relative to the root. 52 | * 53 | * @return string 54 | * 55 | */ 56 | public function path($spec) 57 | { 58 | $spec = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $spec); 59 | return $this->root . trim($spec, DIRECTORY_SEPARATOR); 60 | } 61 | 62 | /** 63 | * 64 | * Equivalent of file_get_contents(), with error capture. 65 | * 66 | * @param string $file The file to read from. 67 | * 68 | * @return string 69 | * 70 | */ 71 | public function get($file) 72 | { 73 | $file = $this->path($file); 74 | 75 | $level = error_reporting(0); 76 | $result = file_get_contents($file); 77 | error_reporting($level); 78 | 79 | if ($result !== false) { 80 | return $result; 81 | } 82 | 83 | $error = error_get_last(); 84 | throw new Exception($error['message']); 85 | } 86 | 87 | /** 88 | * 89 | * Equivalent of file_put_contents(), with error capture. 90 | * 91 | * @param string $file The file to write to. 92 | * 93 | * @param string $data The data to write to the file. 94 | * 95 | */ 96 | public function put($file, $data) 97 | { 98 | $file = $this->path($file); 99 | 100 | $level = error_reporting(0); 101 | $result = file_put_contents($file, $data); 102 | error_reporting($level); 103 | 104 | if ($result !== false) { 105 | return $result; 106 | } 107 | 108 | $error = error_get_last(); 109 | throw new Exception($error['message']); 110 | } 111 | 112 | /** 113 | * 114 | * Equivalent of parse_ini_file(), with error capture. 115 | * 116 | * @param string $file The file to read from. 117 | * 118 | * @param bool $sections Process sections within the file? 119 | * 120 | * @param int $mode The INI scanner mode. 121 | * 122 | * @return array 123 | * 124 | * @see parse_ini_file() 125 | * 126 | */ 127 | public function parseIni($file, $sections = false, $mode = INI_SCANNER_NORMAL) 128 | { 129 | $file = $this->path($file); 130 | 131 | $level = error_reporting(0); 132 | $result = parse_ini_file($file, $sections, $mode); 133 | error_reporting($level); 134 | 135 | if ($result !== false) { 136 | return $result; 137 | } 138 | 139 | $error = error_get_last(); 140 | throw new Exception($error['message']); 141 | } 142 | 143 | /** 144 | * 145 | * Checks to see if one of the arguments is a readable file within the root. 146 | * 147 | * @param string $file The file to check. 148 | * 149 | * @return bool 150 | * 151 | */ 152 | public function isFile($file) 153 | { 154 | $path = $this->path($file); 155 | return file_exists($path) && is_readable($path); 156 | } 157 | 158 | /** 159 | * 160 | * Deletes a file, if it exists. 161 | * 162 | * @param string $file The file to delete. 163 | * 164 | * @return mixed 165 | * 166 | */ 167 | public function unlink($file) 168 | { 169 | if ($this->isFile($file)) { 170 | return unlink($this->path($file)); 171 | } 172 | } 173 | 174 | /** 175 | * 176 | * Checks to see if the argument is a directory within the root. 177 | * 178 | * @param string $dir The directory to check. 179 | * 180 | * @return bool 181 | * 182 | */ 183 | public function isDir($dir) 184 | { 185 | $dir = $this->path($dir); 186 | return is_dir($dir); 187 | } 188 | 189 | /** 190 | * 191 | * Makes a directory within the root. 192 | * 193 | * @param string $dir The directory to make. 194 | * 195 | * @param string $mode The permissions. 196 | * 197 | * @param string $deep Create intervening directories? 198 | * 199 | */ 200 | public function mkdir($dir, $mode = 0777, $deep = true) 201 | { 202 | $dir = $this->path($dir); 203 | 204 | $level = error_reporting(0); 205 | $result = mkdir($dir, $mode, $deep); 206 | error_reporting($level); 207 | 208 | if ($result !== false) { 209 | return; 210 | } 211 | 212 | $error = error_get_last(); 213 | throw new Exception($error['message']); 214 | } 215 | 216 | /** 217 | * 218 | * Removes a directory, if it exists. 219 | * 220 | * @param string $dir The directory to remove. 221 | * 222 | * @return mixed 223 | * 224 | */ 225 | public function rmdir($dir) 226 | { 227 | if ($this->isDir($dir)) { 228 | return rmdir($this->path($dir)); 229 | } 230 | } 231 | 232 | /** 233 | * 234 | * Gets the system temporary directory, with an optional subdirectory; 235 | * creates the subdirectory if needed. 236 | * 237 | * @param string $sub The temporary subdirectory. 238 | * 239 | * @return string The path to the temporary directory. 240 | * 241 | */ 242 | public function sysTempDir($sub = null) 243 | { 244 | $sub = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $sub); 245 | 246 | $dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR 247 | . ltrim($sub, DIRECTORY_SEPARATOR); 248 | 249 | if (is_dir($dir)) { 250 | return $dir; 251 | } 252 | 253 | $level = error_reporting(0); 254 | $result = mkdir($dir, 0777, true); 255 | error_reporting($level); 256 | 257 | if ($result !== false) { 258 | return $dir; 259 | } 260 | 261 | $error = error_get_last(); 262 | throw new Exception($error['message']); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/ProducerContainer.php: -------------------------------------------------------------------------------- 1 | homedir = $homedir; 78 | $this->repodir = $repodir; 79 | $this->logger = new Stdlog($stdout, $stderr); 80 | } 81 | 82 | /** 83 | * 84 | * Returns a new Command object. 85 | * 86 | * @param string $name The command name. 87 | * 88 | * @return Command\CommandInterface 89 | * 90 | */ 91 | public function newCommand($name) 92 | { 93 | $name = trim($name); 94 | if (! $name || $name == 'help') { 95 | return new Command\Help($this->logger); 96 | } 97 | 98 | $class = "Producer\\Command\\" . ucfirst($name); 99 | if (! class_exists($class)) { 100 | throw new Exception("Command '$name' not found."); 101 | } 102 | 103 | $homefs = $this->newFsio($this->homedir); 104 | $repofs = $this->newFsio($this->repodir); 105 | $config = $this->newConfig($homefs, $repofs); 106 | 107 | $repo = $this->newRepo($repofs, $config); 108 | $api = $this->newApi($repo->getOrigin(), $config); 109 | 110 | return new $class($this->logger, $repo, $api, $config); 111 | } 112 | 113 | /** 114 | * 115 | * Returns a new filesystem I/O object. 116 | * 117 | * @param string $dir The root directory for the filesystem. 118 | * 119 | * @return Fsio 120 | * 121 | */ 122 | protected function newFsio($root) 123 | { 124 | return new Fsio($root); 125 | } 126 | 127 | /** 128 | * 129 | * Returns a new Config object. 130 | * 131 | * @param Fsio $homefs 132 | * 133 | * @param Fsio $repofs 134 | * 135 | * @return Config 136 | * 137 | */ 138 | protected function newConfig(Fsio $homefs, Fsio $repofs) 139 | { 140 | return new Config($homefs, $repofs); 141 | } 142 | 143 | /** 144 | * 145 | * Returns a new Repo object. 146 | * 147 | * @param Fsio $fsio A filesystem I/O object for the repository. 148 | * 149 | * @param Config $config Global and project configuration. 150 | * 151 | * @return RepoInterface 152 | * 153 | */ 154 | protected function newRepo($fsio, Config $config) 155 | { 156 | if ($fsio->isDir('.git')) { 157 | return new Repo\Git($fsio, $this->logger, $config); 158 | }; 159 | 160 | if ($fsio->isDir('.hg')) { 161 | return new Repo\Hg($fsio, $this->logger, $config); 162 | } 163 | 164 | throw new Exception("Could not find .git or .hg files."); 165 | } 166 | 167 | /** 168 | * 169 | * Returns a new Api object. 170 | * 171 | * @param string $origin The repository remote origin. 172 | * 173 | * @param Config $config A config object. 174 | * 175 | * @return RepoInterface 176 | * 177 | */ 178 | protected function newApi($origin, Config $config) 179 | { 180 | switch (true) { 181 | 182 | case ($this->isGithubBased($origin, $config)): 183 | return new Api\Github( 184 | $origin, 185 | $config->get('github_hostname'), 186 | $config->get('github_username'), 187 | $config->get('github_token') 188 | ); 189 | 190 | case ($this->isGitlabBased($origin, $config)): 191 | return new Api\Gitlab( 192 | $origin, 193 | $config->get('gitlab_hostname'), 194 | $config->get('gitlab_token') 195 | ); 196 | 197 | case ($this->isBitbucketBased($origin, $config) !== false): 198 | return new Api\Bitbucket( 199 | $origin, 200 | $config->get('bitbucket_hostname'), 201 | $config->get('bitbucket_username'), 202 | $config->get('bitbucket_password') 203 | ); 204 | 205 | default: 206 | throw new Exception("Producer will not work with {$origin}."); 207 | 208 | } 209 | } 210 | 211 | /** 212 | * 213 | * Is GitHub-based if hostname is `api.github.com` and the repo origin 214 | * contains `github.com`. 215 | * 216 | * Alternatively, the project is using GitHub Enterprise if hostname is NOT 217 | * `api.github.com` and the configured hostname matches the repo origin. 218 | * 219 | * @param $origin string The repo origin. 220 | * 221 | * @param $config Config A config object. 222 | * 223 | * @return bool 224 | * 225 | */ 226 | protected function isGithubBased($origin, Config $config) 227 | { 228 | if ($config->get('github_hostname') === 'api.github.com') { 229 | return strpos($origin, 'github.com') !== false; 230 | } else { 231 | return strpos($origin, $config->get('github_hostname')) !== false; 232 | } 233 | } 234 | 235 | /** 236 | * 237 | * Is GitLab-based if hostname is `gitlab.com` and the repo origin contains 238 | * `github.com`. 239 | * 240 | * Alternatively, the project is using self-hosted GitLab if hostname is NOT 241 | * `gitlab.com` and the configured hostname matches the repo origin. 242 | * 243 | * @param $origin string The repo origin. 244 | * 245 | * @param $config Config A config object. 246 | * 247 | * @return bool 248 | * 249 | */ 250 | protected function isGitlabBased($origin, Config $config) 251 | { 252 | if ($config->get('gitlab_hostname') === 'gitlab.com') { 253 | return strpos($origin, 'gitlab.com') !== false; 254 | } else { 255 | return strpos($origin, $config->get('gitlab_hostname')) !== false; 256 | } 257 | } 258 | 259 | /** 260 | * 261 | * Is Bitbucket-based if hostname is `api.bitbucket.org` and the repo origin 262 | * contains `bitbucket.org`. 263 | * 264 | * Alternatively, the project is using self-hosted Bitbucket if hostname is 265 | * NOT `bitbucket.org` and the configured hostname matches the repo origin. 266 | * 267 | * @param $origin string The repo origin. 268 | * 269 | * @param $config Config A config object. 270 | * 271 | * @return bool 272 | * 273 | */ 274 | protected function isBitbucketBased($origin, Config $config) 275 | { 276 | if ($config->get('bitbucket_hostname') === 'api.bitbucket.org') { 277 | return strpos($origin, 'bitbucket.org') !== false; 278 | } else { 279 | return strpos($origin, $config->get('bitbucket_hostname')) !== false; 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/Repo/AbstractRepo.php: -------------------------------------------------------------------------------- 1 | fsio = $fsio; 84 | $this->logger = $logger; 85 | $this->config = $config; 86 | $this->setOrigin(); 87 | } 88 | 89 | /** 90 | * 91 | * Retains the remote origin for the repository from the VCS config file. 92 | * 93 | */ 94 | abstract protected function setOrigin(); 95 | 96 | 97 | /** 98 | * 99 | * Returns the remote origin for the repository. 100 | * 101 | * @return string 102 | * 103 | */ 104 | public function getOrigin() 105 | { 106 | return $this->origin; 107 | } 108 | 109 | /** 110 | * 111 | * Returns the Composer package name. 112 | * 113 | * @return string 114 | * 115 | */ 116 | public function getPackage() 117 | { 118 | return $this->getComposer()->name; 119 | } 120 | 121 | /** 122 | * 123 | * Executes shell commands. 124 | * 125 | * @param string $cmd The shell command to execute. 126 | * 127 | * @param array $output Returns shell output through the reference. 128 | * 129 | * @param mixed $return Returns the exit code through this reference. 130 | * 131 | * @return string The last line of output. 132 | * 133 | * @see exec 134 | */ 135 | protected function shell($cmd, &$output = [], &$return = null) 136 | { 137 | $cmd = str_replace('; ', ';\\' . PHP_EOL, $cmd); 138 | $this->logger->debug("> $cmd"); 139 | $output = null; 140 | $last = exec($cmd, $output, $return); 141 | foreach ($output as $line) { 142 | $this->logger->debug("< $line"); 143 | } 144 | return $last; 145 | } 146 | 147 | /** 148 | * 149 | * Validates the `composer.json` file. 150 | * 151 | */ 152 | public function validateComposer() 153 | { 154 | $last = $this->shell('composer validate', $output, $return); 155 | if ($return) { 156 | throw new Exception($last); 157 | } 158 | } 159 | 160 | /** 161 | * 162 | * Gets the `composer.json` file data. 163 | * 164 | * @return object 165 | * 166 | */ 167 | public function getComposer() 168 | { 169 | if (! $this->composer) { 170 | $this->composer = json_decode($this->fsio->get('composer.json')); 171 | } 172 | return $this->composer; 173 | } 174 | 175 | /** 176 | * 177 | * Checks all support files *except* for CHANGES; this is because updating 178 | * the changes should be the very last thing to deal with. 179 | * 180 | */ 181 | public function checkSupportFiles() 182 | { 183 | $files = $this->config->get('files'); 184 | unset($files['changes']); 185 | foreach ($files as $file) { 186 | $this->checkSupportFile($file); 187 | } 188 | } 189 | 190 | /** 191 | * 192 | * Checks one support file. 193 | * 194 | * @param string $file The file to check. 195 | * 196 | */ 197 | protected function checkSupportFile($file) 198 | { 199 | if (! $this->fsio->isFile($file)) { 200 | throw new Exception("The file {$file} is missing."); 201 | } 202 | if (trim($this->fsio->get($file)) === '') { 203 | throw new Exception("The file {$file} is empty."); 204 | } 205 | } 206 | 207 | /** 208 | * 209 | * Checks to see that the current year is in the LICENSE. 210 | * 211 | */ 212 | public function checkLicenseYear() 213 | { 214 | $license = $this->fsio->get($this->config->get('files')['license']); 215 | $year = date('Y'); 216 | if (strpos($license, $year) === false) { 217 | $this->logger->warning('The LICENSE copyright year (or range of years) looks out-of-date.'); 218 | } 219 | } 220 | 221 | /** 222 | * 223 | * Runs the tests using phpunit. 224 | * 225 | */ 226 | public function checkTests() 227 | { 228 | $this->shell('composer update'); 229 | 230 | $command = $this->config->get('commands')['phpunit']; 231 | 232 | $last = $this->shell($command, $output, $return); 233 | if ($return) { 234 | throw new Exception($last); 235 | } 236 | $this->checkStatus(); 237 | } 238 | 239 | /** 240 | * 241 | * Gets the contents of the CHANGES file. 242 | * 243 | * If the file is named CHANGELOG(*), then this will look for the first 244 | * set of double-hashes (indicating a version heading) and only return the 245 | * text until the next set of double-hashes. If there is no matching pair 246 | * of double-hashes, it will return the entire text. 247 | * 248 | */ 249 | public function getChanges() 250 | { 251 | $file = $this->config->get('files')['changes']; 252 | 253 | $text = $this->fsio->get($file); 254 | $name = substr(basename(strtoupper($file)), 0, 9); 255 | if ($name !== 'CHANGELOG') { 256 | return $text; 257 | } 258 | 259 | preg_match('/(\n\#\# .*\n)(.*)(\n\#\# )/Ums', $text, $matches); 260 | if (isset($matches[2])) { 261 | return trim($matches[2]); 262 | } 263 | 264 | return $text; 265 | } 266 | 267 | /** 268 | * 269 | * Checks the `src/` docblocks using phpdoc. 270 | * 271 | * @param string $version A version number. It provided, will skip checking 272 | * if the version is 0.*, dev, or alpha; if not provided, will never skip. 273 | * 274 | */ 275 | public function checkDocblocks($version = null) 276 | { 277 | switch (true) { 278 | case substr($version, 0, 2) == '0.': 279 | $skip = '0.x'; 280 | break; 281 | case strpos($version, 'dev') !== false: 282 | $skip = 'dev'; 283 | break; 284 | case strpos($version, 'alpha') !== false: 285 | $skip = 'alpha'; 286 | break; 287 | default: 288 | $skip = false; 289 | } 290 | 291 | if ($skip) { 292 | $this->logger->info("Skipping docblock check for $skip release."); 293 | return; 294 | } 295 | 296 | // where to write validation records? 297 | $target = $this->fsio->sysTempDir('/phpdoc/' . $this->getPackage()); 298 | 299 | // remove previous validation records, if any 300 | $this->shell("rm -rf {$target}"); 301 | 302 | // validate 303 | $phpdoc = $this->config->get('commands')['phpdoc']; 304 | $command = "{$phpdoc} -d src/ -t {$target} --force --verbose --template=xml"; 305 | $line = $this->shell($command, $output, $return); 306 | 307 | // get the XML file 308 | $xml = simplexml_load_file("{$target}/structure.xml"); 309 | 310 | // what is the expected @package name? 311 | $expectPackage = $this->getPackage(); 312 | $customPackage = $this->config->get('package'); 313 | if ($customPackage) { 314 | $expectPackage = $customPackage; 315 | } 316 | 317 | // are there missing or misvalued @package tags? 318 | $missing = false; 319 | foreach ($xml->file as $file) { 320 | // class-level tag (don't care about file-level) 321 | $actualPackage = $file->class['package'] . $file->interface['package']; 322 | if ($actualPackage != $expectPackage) { 323 | $missing = true; 324 | $class = $file->class->full_name . $file->interface->full_name; 325 | $message = " Expected class-level @package {$expectPackage}, " 326 | . "actual value '{$actualPackage}', " 327 | . "in class {$class}"; 328 | $this->logger->error($message); 329 | } 330 | } 331 | 332 | if ($missing) { 333 | throw new Exception('Docblocks do not appear valid.'); 334 | } 335 | 336 | // are there other invalidities? 337 | foreach ($output as $line) { 338 | // this line indicates the end of parsing 339 | if (substr($line, 0, 41) == 'Transform analyzed project into artifacts') { 340 | break; 341 | } 342 | // invalid lines have 2-space indents 343 | if (substr($line, 0, 2) == ' ') { 344 | throw new Exception('Docblocks do not appear valid.'); 345 | } 346 | } 347 | } 348 | 349 | /** 350 | * 351 | * Checks to see if the changes are up to date. 352 | * 353 | */ 354 | public function checkChanges() 355 | { 356 | $file = $this->config->get('files')['changes']; 357 | $this->checkSupportFile($file); 358 | 359 | $lastChangelog = $this->getChangesDate(); 360 | $this->logger->info("Last changes date is $lastChangelog."); 361 | 362 | $lastCommit = $this->getLastCommitDate(); 363 | $this->logger->info("Last commit date is $lastCommit."); 364 | 365 | if ($lastChangelog == $lastCommit) { 366 | $this->logger->info('Changes appear up to date.'); 367 | return; 368 | } 369 | 370 | $this->logger->error('Changes appear out of date.'); 371 | $this->logger->error('Log of possible missing changes:'); 372 | $this->logSinceDate($lastChangelog); 373 | throw new Exception('Please update and commit the changes.'); 374 | } 375 | } 376 | --------------------------------------------------------------------------------