├── tests ├── Fixtures │ ├── small.txt │ ├── broken.xml │ ├── large.txt │ ├── crap4j.xml │ ├── clover.xml │ └── oldClover.xml ├── Clover │ ├── EmptyCloverFileTest.php │ ├── Analysis │ │ └── MethodTest.php │ ├── DiffServiceTest.php │ ├── Crap4JTest.php │ └── CloverFileTest.php ├── Gitlab │ ├── EmojiGeneratorTest.php │ └── MessageTest.php └── Commands │ └── ConfigTest.php ├── .gitignore ├── phpstan.neon ├── src ├── Gitlab │ ├── BuildNotFoundException.php │ ├── MergeRequestNotFoundException.php │ ├── EmojiGenerator.php │ ├── SendCommentService.php │ ├── Message.php │ └── BuildService.php ├── Clover │ ├── CoverageDetectorInterface.php │ ├── CrapMethodFetcherInterface.php │ ├── EmptyCloverFile.php │ ├── Analysis │ │ ├── Difference.php │ │ └── Method.php │ ├── CrapMethodMerger.php │ ├── Crap4JFile.php │ ├── DiffService.php │ └── CloverFile.php ├── Git │ └── GitRepository.php └── Commands │ ├── Config.php │ └── RunCommand.php ├── box.json ├── phpunit.xml.dist ├── washingmachine ├── LICENSE ├── composer.json ├── .travis.yml └── README.md /tests/Fixtures/small.txt: -------------------------------------------------------------------------------- 1 | smalltext -------------------------------------------------------------------------------- /tests/Fixtures/broken.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | artifact.zip 3 | test.txt 4 | /build 5 | /washingmachine.phar 6 | /box.phar 7 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | 4 | #includes: 5 | # - vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon -------------------------------------------------------------------------------- /src/Gitlab/BuildNotFoundException.php: -------------------------------------------------------------------------------- 1 | assertSame(0.0, $empty->getCoveragePercentage()); 13 | $this->assertSame([], $empty->getMethods()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /box.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "washingmachine", 3 | "output": "washingmachine.phar", 4 | "chmod": "0755", 5 | "compactors": [ 6 | "Herrera\\Box\\Compactor\\Php" 7 | ], 8 | "extract": false, 9 | "files": [ 10 | "LICENSE" 11 | ], 12 | "finder": [ 13 | { 14 | "name": ["*.php"], 15 | "exclude": ["tests"], 16 | "in": ["vendor", "src"] 17 | } 18 | ], 19 | "git-commit": "git-commit", 20 | "web": false, 21 | "stub": true 22 | 23 | } -------------------------------------------------------------------------------- /tests/Gitlab/EmojiGeneratorTest.php: -------------------------------------------------------------------------------- 1 | assertSame('', $generator->getEmoji(0)); 15 | $this->assertSame(':innocent:', $generator->getEmoji(1)); 16 | $this->assertSame(':slight_frown:', $generator->getEmoji(80)); 17 | $this->assertSame(':skull_crossbones:', $generator->getEmoji(1000)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Clover/Analysis/MethodTest.php: -------------------------------------------------------------------------------- 1 | merge($method2); 14 | 15 | $this->assertSame('public', $mergedMethod->getVisibility()); 16 | $this->assertSame(42, $mergedMethod->getCount()); 17 | $this->assertSame('file', $mergedMethod->getFile()); 18 | $this->assertSame(42, $mergedMethod->getLine()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Clover/EmptyCloverFile.php: -------------------------------------------------------------------------------- 1 | getMeaningfulDifferences($cloverFile, $oldCloverFile); 17 | 18 | $this->assertCount(1, $differences); 19 | 20 | $difference = $differences[0]; 21 | $this->assertSame($difference->getCrapScore(), 2.03); 22 | $this->assertSame($difference->getCrapDifference(), -40.27); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | ./tests/ 17 | 18 | 19 | 20 | 21 | src 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /washingmachine: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | function() { 20 | return new RunCommand(); 21 | }, 22 | Application::class => function(ContainerInterface $container) { 23 | $app = new Application('washingmachine'); 24 | $app->add($container->get(RunCommand::class)); 25 | return $app; 26 | } 27 | ]); 28 | 29 | $app = $container->get(Application::class); 30 | /* @var $app Application */ 31 | $app->run(); 32 | -------------------------------------------------------------------------------- /tests/Clover/Crap4JTest.php: -------------------------------------------------------------------------------- 1 | expectException(\RuntimeException::class); 12 | Crap4JFile::fromFile(__DIR__.'/../Fixtures/broken.xml', '/'); 13 | } 14 | 15 | public function testNotExistFile() 16 | { 17 | $this->expectException(\RuntimeException::class); 18 | Crap4JFile::fromFile(__DIR__.'/../Fixtures/notexist.xml', '/'); 19 | } 20 | 21 | public function testGetMethods() 22 | { 23 | $crap4JFile = Crap4JFile::fromFile(__DIR__.'/../Fixtures/crap4j.xml'); 24 | 25 | $methods = $crap4JFile->getMethods(); 26 | 27 | $this->assertCount(2, $methods); 28 | $construct = $methods['Test\\Controllers\\TestController::getProjectsForClient']; 29 | $this->assertSame('getProjectsForClient', $construct->getMethodName()); 30 | $this->assertSame(12.0, $construct->getCrap()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 David Négrier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /src/Git/GitRepository.php: -------------------------------------------------------------------------------- 1 | extractFromCommand('git merge-base --is-ancestor ' . escapeshellarg($commit1) . ' ' . escapeshellarg($commit2)); 15 | } catch (GitException $e) { 16 | // The command will return exit code 1 if $commit1 is an ancestor of $commit2 17 | // Exit code one triggers an exception. We catch it. 18 | return $commit1; 19 | } 20 | 21 | 22 | $results = $this->extractFromCommand('git merge-base ' . escapeshellarg($commit1). ' '. escapeshellarg($commit2)); 23 | 24 | return $results[0]; 25 | } 26 | 27 | public function getLatestCommitForBranch(string $branch) : string 28 | { 29 | $results = $this->extractFromCommand('git log -n 1 --pretty=format:"%H" ' . escapeshellarg($branch)); 30 | 31 | return $results[0]; 32 | } 33 | } -------------------------------------------------------------------------------- /src/Gitlab/EmojiGenerator.php: -------------------------------------------------------------------------------- 1 | emojiMap = $emojiMap; 16 | } 17 | 18 | public function getEmoji(float $score) : string 19 | { 20 | $lastValue = ''; 21 | foreach ($this->emojiMap as $priority => $value) { 22 | if ($priority > $score) { 23 | return $lastValue; 24 | } 25 | $lastValue = $value; 26 | } 27 | return $lastValue; 28 | } 29 | 30 | public static function createCrapScoreEmojiGenerator() : self 31 | { 32 | $emojiMap = [ 33 | 1 => ':innocent:', 34 | 31 => ':neutral_face:', 35 | 50 => ':sweat:', 36 | 80 => ':slight_frown:', 37 | 120 => ':sob:', 38 | 300 => ':scream:', 39 | 600 => ':radioactive:', 40 | 900 => ':skull_crossbones:' 41 | ]; 42 | return new self($emojiMap); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Fixtures/crap4j.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 2017-02-21 13:38:10 5 | 6 | Method Crap Stats 7 | 1148 8 | 24 9 | 188 10 | 5444 11 | 2.09 12 | 13 | 14 | 15 | Test\Controllers 16 | TestController 17 | getProjectsForClient 18 | getProjectsForClient($id) 19 | getProjectsForClient($id) 20 | 12 21 | 3 22 | 0 23 | 0 24 | 25 | 26 | Test\Controllers 27 | TestController 28 | getNewLine 29 | getNewLine($index) 30 | getNewLine($index) 31 | 2 32 | 1 33 | 0 34 | 0 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/Clover/Analysis/Difference.php: -------------------------------------------------------------------------------- 1 | newMethod = $newMethod; 24 | $this->oldMethod = $oldMethod; 25 | } 26 | 27 | public function getMethodFullName() : string 28 | { 29 | return $this->newMethod->getFullName(); 30 | } 31 | 32 | public function getMethodShortName() : string 33 | { 34 | return $this->newMethod->getShortName(); 35 | } 36 | 37 | public function getCrapScore() : float 38 | { 39 | return $this->newMethod->getCrap(); 40 | } 41 | 42 | public function isNew() : bool 43 | { 44 | return $this->oldMethod === null; 45 | } 46 | 47 | public function getCrapDifference(): float 48 | { 49 | return $this->newMethod->getCrap() - $this->oldMethod->getCrap(); 50 | } 51 | 52 | public function getFile() 53 | { 54 | return $this->newMethod->getFile(); 55 | } 56 | 57 | public function getLine() 58 | { 59 | return $this->newMethod->getLine(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Clover/CrapMethodMerger.php: -------------------------------------------------------------------------------- 1 | file1 = $file1; 31 | $this->file2 = $file2; 32 | } 33 | 34 | /** 35 | * Returns an array of method objects, indexed by method full name. 36 | * 37 | * @return Method[] 38 | */ 39 | public function getMethods(): array 40 | { 41 | $methods = $this->file1->getMethods(); 42 | $toMergeMethods = $this->file2->getMethods(); 43 | 44 | foreach ($toMergeMethods as $name => $toMergeMethod) { 45 | $methods[$name] = isset($methods[$name]) ? $methods[$name]->merge($toMergeMethod) : $toMergeMethod; 46 | } 47 | 48 | return $methods; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/Clover/CloverFileTest.php: -------------------------------------------------------------------------------- 1 | getCoveragePercentage(); 13 | 14 | $this->assertSame(0.81818181818181823, $result); 15 | } 16 | 17 | public function testBadFile() 18 | { 19 | $this->expectException(\RuntimeException::class); 20 | CloverFile::fromFile(__DIR__.'/../Fixtures/broken.xml', '/'); 21 | } 22 | 23 | public function testNotExistFile() 24 | { 25 | $this->expectException(\RuntimeException::class); 26 | CloverFile::fromFile(__DIR__.'/../Fixtures/notexist.xml', '/'); 27 | } 28 | 29 | public function testGetMethods() 30 | { 31 | $cloverFile = CloverFile::fromFile(__DIR__.'/../Fixtures/clover.xml', '/home/david/projects/washing-machine'); 32 | 33 | $methods = $cloverFile->getMethods(); 34 | 35 | $this->assertCount(2, $methods); 36 | $construct = $methods['TheCodingMachine\\WashingMachine\\Clover\\CloverFile::__construct']; 37 | $this->assertSame('__construct', $construct->getMethodName()); 38 | $this->assertSame('src/Clover/CloverFile.php', $construct->getFile()); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thecodingmachine/washingmachine", 3 | "description": "CI tool that integrates with Gitlab to show variations in the CRAP index", 4 | "type": "project", 5 | "repositories": [ 6 | { 7 | "type": "vcs", 8 | "url": "https://github.com/moufmouf/php-gitlab-api.git" 9 | } 10 | ], 11 | "require": { 12 | "php": ">=7.1", 13 | "m4tthumphrey/php-gitlab-api": "^9.3", 14 | "guzzlehttp/psr7": "^1.2", 15 | "php-http/guzzle6-adapter": "^1.0", 16 | "symfony/console": "^3.0", 17 | "mouf/picotainer": "^1.0", 18 | "symfony/filesystem": "^3.0", 19 | "czproject/git-php": "^3.8" 20 | }, 21 | "require-dev": { 22 | "phpunit/phpunit": "^5.7", 23 | "satooshi/php-coveralls": "^1.0", 24 | "phpstan/phpstan": "^0.9" 25 | }, 26 | "license": "MIT", 27 | "authors": [ 28 | { 29 | "name": "David Négrier", 30 | "email": "d.negrier@thecodingmachine.com" 31 | } 32 | ], 33 | "autoload": { 34 | "psr-4": { 35 | "TheCodingMachine\\WashingMachine\\": "src/" 36 | } 37 | }, 38 | "autoload-dev": { 39 | "psr-4": { 40 | "TheCodingMachine\\WashingMachine\\": "tests/" 41 | } 42 | }, 43 | "bin": [ "washingmachine" ], 44 | "config": { 45 | "platform": { 46 | "php": "7.1" 47 | } 48 | }, 49 | "scripts": { 50 | "phpstan": "phpstan analyse src -c phpstan.neon --level=4 --no-progress -vvv" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Gitlab/SendCommentService.php: -------------------------------------------------------------------------------- 1 | client = $client; 23 | $this->diffService = $diffService; 24 | } 25 | 26 | 27 | public function sendDifferencesCommentsInCommit(CrapMethodFetcherInterface $cloverFile, CrapMethodFetcherInterface $previousCloverFile, string $projectName, string $commitId, string $gitlabUrl) 28 | { 29 | $differences = $this->diffService->getMeaningfulDifferences($cloverFile, $previousCloverFile); 30 | 31 | foreach ($differences as $difference) { 32 | $message = new Message(); 33 | $message->addDifference($difference, $commitId, $gitlabUrl, $projectName); 34 | 35 | $options = []; 36 | if ($difference->getFile() !== null) { 37 | $options = [ 38 | 'path' => $difference->getFile(), 39 | 'line' => $difference->getLine(), 40 | 'line_type' => 'new' 41 | ]; 42 | } 43 | 44 | $this->client->repositories->createCommitComment($projectName, $commitId, (string) $message, $options); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Fixtures/clover.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /tests/Fixtures/oldClover.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | sudo: false 3 | env: 4 | global: 5 | - DEFAULT_COMPOSER_FLAGS="--no-interaction --no-progress --optimize-autoloader" 6 | - TASK_TESTS=1 7 | - TASK_TESTS_COVERAGE=0 8 | - TASK_CS=1 9 | - TASK_SCA=0 10 | matrix: 11 | fast_finish: true 12 | include: 13 | - php: 7.1 14 | env: DEPLOY=yes 15 | - php: 7.2 16 | cache: 17 | directories: 18 | - "$HOME/.composer/cache" 19 | before_install: 20 | - travis_retry composer global require $DEFAULT_COMPOSER_FLAGS hirak/prestissimo 21 | install: 22 | - travis_retry composer install $DEFAULT_COMPOSER_FLAGS $COMPOSER_FLAGS 23 | - composer info -D | sort 24 | script: 25 | - vendor/bin/phpunit --verbose; 26 | - composer phpstan 27 | after_success: 28 | - vendor/bin/coveralls -v 29 | before_deploy: 30 | - curl -LSs http://box-project.github.io/box2/installer.php | php 31 | - php box.phar --version 32 | - composer install $DEFAULT_COMPOSER_FLAGS --no-dev 33 | - composer info -D | sort 34 | - php -d phar.readonly=false box.phar build 35 | deploy: 36 | provider: releases 37 | api_key: 38 | secure: sSLPXBctXqf3OLd8CV8gTMTpDHyBQsU8/uTV9CrwCCkkleVOjDz11aRLbgoJ8HaN0wb6n3LQ5FyClcF/lddNdo9D6JZXuZK9CzHFwgUtqGSH3mhCd0cNFgkLV4UYU30i+4MlL+Ov93dmEXFvhAhSEI5zM0NYMDvfp/hTsqdbFUEZOKzCWRWh9RHhz0cGmrdTpv/dyUST+pozAyoB9SaiGOIOYhNtvMSthE7IaivV2Q0fS1tOqP/zfSxGzS04/MP1C1b+Cb1dTJA47oYTbn8ax8LNXT8xO4JQeIOzcqLjcYjDQeWSKyfJzwzAItha+cMqRDSGlquGN8WKrRVc4OESac2oNWulXBRTb6cJ8ZjH0bsEm4FddoLHocPFzWjccoVaeeYorMYCQvyL/ehfPfEKaNAf0+0rxxF2k02OONH8tTpGDwLb7f1u9vFL7W5RxGqGl051pmOSftFSBWHr9h6FZyjUF5jpocrVy5nb7J68CnW+CNOp9Mx494M0l5yclqCd4ej06ZmH+dsLYghOJpZz/qkbN9iW1ZfWxxGzSC1HwM1Mz+7ER6kRD43n+YQQjCqchxA9h4EZ5Kx8Pa9o+48Y29LmP9ap1ISbVS+U4ACYw2SCBkz5mrohBBWkqrwNwUNmC06mALL36Z4cUo7nwTnGtW11vc+CFGsusfYVw/0T1nw= 39 | file: washingmachine.phar 40 | skip_cleanup: true 41 | on: 42 | repo: thecodingmachine/washingmachine 43 | tags: true -------------------------------------------------------------------------------- /tests/Gitlab/MessageTest.php: -------------------------------------------------------------------------------- 1 | addDifferencesHtml($cloverFile, $cloverFile, new DiffService(5, 5, 20), 42, 'http://gitlab', 'my_group/my_project'); 20 | 21 | $this->assertSame("No meaningful differences in code complexity detected.\n", (string) $message); 22 | } 23 | 24 | public function testFromCrap4J() 25 | { 26 | $crap4JFile = Crap4JFile::fromFile(__DIR__.'/../Fixtures/crap4j.xml'); 27 | 28 | $message = new Message(); 29 | $message->addDifferencesHtml($crap4JFile, EmptyCloverFile::create(), new DiffService(0, 0, 20), 42, 'http://gitlab', 'my_group/my_project'); 30 | 31 | $msg = (string) $message; 32 | 33 | $this->assertContains('TestController::getProjectsForClient', $msg); 34 | $this->assertNotContains('addFile(new \SplFileInfo(__DIR__.'/../Fixtures/small.txt'), 'http://gitlab.example.com', 'foo/bar', 42); 42 | 43 | $str = (string) $message; 44 | 45 | $this->assertContains('small.txt', $str); 46 | $this->assertContains('smalltext', $str); 47 | } 48 | 49 | public function testAddLargeMessage() 50 | { 51 | $message = new Message(); 52 | 53 | $message->addFile(new \SplFileInfo(__DIR__.'/../Fixtures/large.txt'), 'http://gitlab.example.com', 'foo/bar', 42); 54 | 55 | $str = (string) $message; 56 | 57 | $this->assertContains('large.txt', $str); 58 | $this->assertNotContains('largetext', $str); 59 | $this->assertContains('http://gitlab.example.com/', $str); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Clover/Crap4JFile.php: -------------------------------------------------------------------------------- 1 | fileName = $fileName; 33 | $errorReporting = error_reporting(); 34 | $oldErrorReporting = error_reporting($errorReporting & ~E_WARNING); 35 | $crap4JFile->root = simplexml_load_file($fileName); 36 | error_reporting($oldErrorReporting); 37 | if ($crap4JFile->root === false) { 38 | throw new \RuntimeException('Invalid XML file passed or unable to load file: "'.$fileName.'": '.error_get_last()['message']); 39 | } 40 | return $crap4JFile; 41 | } 42 | 43 | public static function fromString(string $string) : Crap4JFile 44 | { 45 | $cloverFile = new self(); 46 | $errorReporting = error_reporting(); 47 | $oldErrorReporting = error_reporting($errorReporting & ~E_WARNING); 48 | $cloverFile->root = simplexml_load_string($string); 49 | error_reporting($oldErrorReporting); 50 | if ($cloverFile->root === false) { 51 | throw new \RuntimeException('Invalid XML file passed or unable to load string: '.error_get_last()['message']); 52 | } 53 | return $cloverFile; 54 | } 55 | 56 | /** 57 | * Returns an array of method objects, indexed by method full name. 58 | * 59 | * @return Method[] 60 | */ 61 | public function getMethods() : array 62 | { 63 | $methods = []; 64 | $methodsElement = $this->root->xpath('/crap_result/methods/method'); 65 | 66 | foreach ($methodsElement as $methodElement) { 67 | $package = (string) $methodElement->package; 68 | $className = (string) $methodElement->className; 69 | $methodName = (string) $methodElement->methodName; 70 | $crap = (int) $methodElement->crap; 71 | $complexity = (int) $methodElement->complexity; 72 | 73 | $method = new Method($methodName, $className, $package, $complexity, $crap); 74 | $methods[$method->getFullName()] = $method; 75 | } 76 | 77 | return $methods; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Clover/DiffService.php: -------------------------------------------------------------------------------- 1 | meaningfulCrapChange = $meaningfulCrapChange; 34 | $this->maxReturnedMethods = $maxReturnedMethods; 35 | $this->crapScoreThreshold = $crapScoreThreshold; 36 | } 37 | 38 | /** 39 | * @param CrapMethodFetcherInterface $newCloverFile 40 | * @param CrapMethodFetcherInterface $oldCloverFile 41 | * @return Difference[] 42 | */ 43 | public function getMeaningfulDifferences(CrapMethodFetcherInterface $newCloverFile, CrapMethodFetcherInterface $oldCloverFile) 44 | { 45 | $newMethods = $newCloverFile->getMethods(); 46 | $oldMethods = $oldCloverFile->getMethods(); 47 | 48 | // Let's keep only methods that are in both files: 49 | $inCommonMethods = array_intersect(array_keys($newMethods), array_keys($oldMethods)); 50 | 51 | // New methods in the new file: 52 | $createdMethods = array_diff(array_keys($newMethods), $inCommonMethods); 53 | 54 | $differences = []; 55 | 56 | foreach ($inCommonMethods as $methodName) { 57 | $change = abs($newMethods[$methodName]->getCrap() - $oldMethods[$methodName]->getCrap()); 58 | if ($change > $this->meaningfulCrapChange && ($newMethods[$methodName]->getCrap() > $this->crapScoreThreshold || $oldMethods[$methodName]->getCrap() > $this->crapScoreThreshold)) { 59 | $differences[] = new Difference($newMethods[$methodName], $oldMethods[$methodName]); 60 | } 61 | } 62 | 63 | foreach ($createdMethods as $methodName) { 64 | $method = $newMethods[$methodName]; 65 | if ($method->getCrap() > $this->crapScoreThreshold) { 66 | $differences[] = new Difference($method, null); 67 | } 68 | } 69 | 70 | // Now, let's order the differences by crap order. 71 | usort($differences, function(Difference $d1, Difference $d2) { 72 | return $d2->getCrapScore() <=> $d1->getCrapScore(); 73 | }); 74 | 75 | // Now, let's limit the number of returned differences 76 | $differences = array_slice(array_values($differences), 0, $this->maxReturnedMethods); 77 | 78 | return $differences; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Clover/Analysis/Method.php: -------------------------------------------------------------------------------- 1 | methodName = $methodName; 53 | $this->className = $className; 54 | $this->namespace = $namespace; 55 | $this->visibility = $visibility; 56 | $this->complexity = $complexity; 57 | $this->crap = $crap; 58 | $this->count = $count; 59 | $this->file = $file; 60 | $this->line = $line; 61 | } 62 | 63 | /** 64 | * @return string 65 | */ 66 | public function getMethodName(): string 67 | { 68 | return $this->methodName; 69 | } 70 | 71 | /** 72 | * @return string 73 | */ 74 | public function getClassName(): string 75 | { 76 | return $this->className; 77 | } 78 | 79 | /** 80 | * @return string 81 | */ 82 | public function getNamespace(): string 83 | { 84 | return $this->namespace; 85 | } 86 | 87 | /** 88 | * @return string 89 | */ 90 | public function getVisibility(): string 91 | { 92 | return $this->visibility; 93 | } 94 | 95 | /** 96 | * @return float 97 | */ 98 | public function getComplexity(): float 99 | { 100 | return $this->complexity; 101 | } 102 | 103 | /** 104 | * @return float 105 | */ 106 | public function getCrap(): float 107 | { 108 | return $this->crap; 109 | } 110 | 111 | /** 112 | * @return int 113 | */ 114 | public function getCount(): int 115 | { 116 | return $this->count; 117 | } 118 | 119 | public function getFullName() : string 120 | { 121 | return $this->namespace.'\\'.$this->className.'::'.$this->methodName; 122 | } 123 | 124 | public function getShortName() : string 125 | { 126 | return $this->className.'::'.$this->methodName; 127 | } 128 | 129 | /** 130 | * @return string 131 | */ 132 | public function getFile() 133 | { 134 | return $this->file; 135 | } 136 | 137 | /** 138 | * @return int 139 | */ 140 | public function getLine() 141 | { 142 | return $this->line; 143 | } 144 | 145 | /** 146 | * Merges the method passed in parameter in this method. 147 | * Any non null property will override this object property. 148 | * Return a NEW object. 149 | * 150 | * @param Method $method 151 | */ 152 | public function merge(Method $method): Method 153 | { 154 | $clone = clone $this; 155 | $clone->visibility = $method->visibility ?? $this->visibility; 156 | $clone->complexity = $method->complexity ?? $this->complexity; 157 | $clone->crap = $method->crap ?? $this->crap; 158 | $clone->count = $method->count ?? $this->count; 159 | $clone->file = $method->file ?? $this->file; 160 | $clone->line = $method->line ?? $this->line; 161 | return $clone; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Clover/CloverFile.php: -------------------------------------------------------------------------------- 1 | fileName = $fileName; 38 | $errorReporting = error_reporting(); 39 | $oldErrorReporting = error_reporting($errorReporting & ~E_WARNING); 40 | $cloverFile->root = simplexml_load_file($fileName); 41 | error_reporting($oldErrorReporting); 42 | if ($cloverFile->root === false) { 43 | throw new \RuntimeException('Invalid XML file passed or unable to load file: "'.$fileName.'": '.error_get_last()['message']); 44 | } 45 | $cloverFile->rootDirectory = rtrim($rootDirectory, '/').'/'; 46 | return $cloverFile; 47 | } 48 | 49 | public static function fromString(string $string, string $rootDirectory) : CloverFile 50 | { 51 | $cloverFile = new self(); 52 | $errorReporting = error_reporting(); 53 | $oldErrorReporting = error_reporting($errorReporting & ~E_WARNING); 54 | $cloverFile->root = simplexml_load_string($string); 55 | error_reporting($oldErrorReporting); 56 | if ($cloverFile->root === false) { 57 | throw new \RuntimeException('Invalid XML file passed or unable to load string: '.error_get_last()['message']); 58 | } 59 | $cloverFile->rootDirectory = rtrim($rootDirectory, '/').'/'; 60 | return $cloverFile; 61 | } 62 | 63 | /** 64 | * @return float 65 | */ 66 | public function getCoveragePercentage() : float 67 | { 68 | $metrics = $this->root->xpath("/coverage/project/metrics"); 69 | 70 | if (count($metrics) !== 1) { 71 | throw new \RuntimeException('Unexpected number of metrics element in XML file. Found '.count($metrics).' elements."'); 72 | } 73 | 74 | $statements = (float) $metrics[0]['statements']; 75 | $coveredStatements = (float) $metrics[0]['coveredstatements']; 76 | 77 | if ($statements === 0.0) { 78 | return 0.0; 79 | } 80 | 81 | return $coveredStatements/$statements; 82 | } 83 | 84 | /** 85 | * Returns an array of method objects, indexed by method full name. 86 | * 87 | * @return Method[] 88 | */ 89 | public function getMethods() : array 90 | { 91 | $methods = []; 92 | $files = $this->root->xpath('//file'); 93 | 94 | $currentClass = null; 95 | $currentNamespace = null; 96 | 97 | foreach ($files as $file) { 98 | foreach ($file as $item) { 99 | if ($item->getName() === 'class') { 100 | $currentClass = (string) $item['name']; 101 | $currentNamespace = (string) $item['namespace']; 102 | } elseif ($item->getName() === 'line') { 103 | // 104 | $type = (string) $item['type']; 105 | if ($type === 'method' && $currentClass !== null) { 106 | $methodName = (string) $item['name']; 107 | $visibility = (string) $item['visibility']; 108 | $complexity = (float) $item['complexity']; 109 | $crap = (float) $item['crap']; 110 | $count = (int) $item['count']; 111 | $line = (int) $item['num']; 112 | $fileName = (string) $file['name']; 113 | 114 | if (strpos($fileName, $this->rootDirectory) === 0) { 115 | $fileName = substr($fileName, strlen($this->rootDirectory)); 116 | } 117 | 118 | $method = new Method($methodName, $currentClass, $currentNamespace, $complexity, $crap, $visibility, $count, $fileName, $line); 119 | $methods[$method->getFullName()] = $method; 120 | 121 | } 122 | } 123 | } 124 | } 125 | 126 | return $methods; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Stable Version](https://poser.pugx.org/thecodingmachine/washingmachine/v/stable)](https://packagist.org/packages/thecodingmachine/washingmachine) 2 | [![Total Downloads](https://poser.pugx.org/thecodingmachine/washingmachine/downloads)](https://packagist.org/packages/thecodingmachine/washingmachine) 3 | [![Latest Unstable Version](https://poser.pugx.org/thecodingmachine/washingmachine/v/unstable)](https://packagist.org/packages/thecodingmachine/washingmachine) 4 | [![License](https://poser.pugx.org/thecodingmachine/washingmachine/license)](https://packagist.org/packages/thecodingmachine/washingmachine) 5 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/thecodingmachine/washingmachine/badges/quality-score.png?b=2.0)](https://scrutinizer-ci.com/g/thecodingmachine/washingmachine/?branch=2.0) 6 | [![Build Status](https://travis-ci.org/thecodingmachine/washingmachine.svg?branch=2.0)](https://travis-ci.org/thecodingmachine/washingmachine) 7 | [![Coverage Status](https://coveralls.io/repos/thecodingmachine/washingmachine/badge.svg?branch=2.0&service=github)](https://coveralls.io/github/thecodingmachine/washingmachine?branch=2.0) 8 | 9 | 10 | # Washing machine 11 | 12 | The *washing machine* is a tool that helps you writing cleaner code by integrating PHPUnit with Gitlab CI. 13 | 14 | As a result, when you perform a merge request in Gitlab, the washing machine will add meaningful information about your code quality. 15 | 16 | ## Usage 17 | 18 | ### Enable Gitlab CI for your project 19 | 20 | First, you need a Gitlab project with continuous integration enabled (so a project with a `.gitlab-ci.yml` file). 21 | 22 | ### Create a personal access token 23 | 24 | Then, you need a [Gitlab API personal access token](https://docs.gitlab.com/ce/api/README.html#personal-access-tokens). 25 | 26 | Got it? 27 | 28 | ### Add a secret variable 29 | 30 | Now, we need to add this token as a "secret variable" of your project (so the CI script can modify the merge request comments): 31 | 32 | Go to your project page in Gitlab 33 | 34 | **Settings ➔ CI/CD Pipelines ➔ Secret Variables** 35 | 36 | - Key: `GITLAB_API_TOKEN` 37 | - Value: the token you just received in previous step 38 | 39 | ### Configure PHPUnit to dump a "clover" test file 40 | 41 | 42 | Let's configure PHPUnit. Go to your `phpunit.xml.dist` file and add: 43 | 44 | ``` 45 | 46 | 47 | 48 | 49 | 50 | ``` 51 | 52 | Note: the "clover.xml" file must be written at the root of your GIT repository, so if your `phpunit.xml.dist` sits in a subdirectory, the correct path will be something like "../../clover.xml". 53 | 54 | Alternatively, washing-machine also knows how to read Crap4J files. Crap4J files contain Crap score, but not code coverage score so you will get slightly less data from Crap4J. The expected file name is "crap4j.xml". 55 | 56 | ### Configure Gitlab CI yml file 57 | 58 | Now, we need to install the *washingmachine*, and get it to run. 59 | 60 | `.gitlab-ci.yml` 61 | ``` 62 | image: php:7.1 63 | 64 | test: 65 | before_script: 66 | - cd /root && composer create-project thecodingmachine/washingmachine washingmachine ^2.0 67 | 68 | script: 69 | - phpdbg -qrr vendor/bin/phpunit 70 | 71 | after_script: 72 | - /root/washingmachine/washingmachine run -v 73 | ``` 74 | 75 | Notice that we need to make sure the PHPDbg extension for PHP is installed. Also, make sure that Xdebug is NOT enabled on your Docker instance. Xdebug can also return code coverage data but is less accurate than PHPDbg, leading to wrong CRAP score results. 76 | 77 | ### Supported Gitlab versions 78 | 79 | - The washingmachine v2.0+ has support for Gitlab 9+. 80 | 81 | If you seek support for older Gitlab versions, here is a list of supported Gitlab versions by washingmachine version: 82 | 83 | - The washingmachine v1.0 => v1.2 has support for Gitlab 8. 84 | - The washingmachine v1.2+ has support for Gitlab 8 and up to Gitlab 9.5. 85 | 86 | ### Adding extra data in the comment 87 | 88 | When the *washingmachine* adds a comment in your merge-request, you can ask it to add additional text. 89 | This text must be stored in a file. 90 | 91 | You simply do: 92 | 93 | ``` 94 | washingmachine run -f file_to_added_to_comments.txt 95 | ``` 96 | 97 | Of course, this file might be the output of a CI tool. 98 | 99 | The *washingmachine* will only display the first 50 lines of the file. If the file is longer, a link to download the file is added at the end of the comment. 100 | 101 | You can also add several files by repeating the "-f" option: 102 | 103 | ``` 104 | washingmachine run -f file1.txt -f file2.txt 105 | ``` 106 | 107 | ### Opening an issue 108 | 109 | When a merge request is open, the *washingmachine* will post comments directly in the merge request. 110 | 111 | If no merge request exists, the *washingmachine* can open an issue in your Gitlab project. 112 | 113 | To open an issue, use the `--open-issue` option: 114 | 115 | ``` 116 | washingmachine run --open-issue 117 | ``` 118 | 119 | Tip: you typically want to add the `--open-issue` tag conditionally if a build fails. Also, the `--open-issue` is ignored if a merge request matches the build. 120 | 121 | ### Adding comments in commits 122 | 123 | The washingmachine can add comments directly in the commits (in addition to adding comments in the merge request). 124 | 125 | To add comments in commits, use the `--add-comments-in-commits` option: 126 | 127 | ``` 128 | washingmachine run --add-comments-in-commits 129 | ``` 130 | 131 | Note: this option was enabled by default in 1.x and has to be manually enabled in 2.x. For each comment, a mail is sent to the committer. This can generate a big number of mails on big commits. You have been warned :) 132 | 133 | -------------------------------------------------------------------------------- /src/Gitlab/Message.php: -------------------------------------------------------------------------------- 1 | getCoveragePercentage(); 24 | $previousCoverage = $previousCoverageDetector->getCoveragePercentage(); 25 | 26 | $additionalText = ''; 27 | $style = ''; 28 | if ($coverage > $previousCoverage + 0.0001) { 29 | $additionalText = sprintf('(+%.2f%%)', ($coverage - $previousCoverage)*100); 30 | $style .= 'background-color: #00994c; color: white'; 31 | } elseif ($coverage < $previousCoverage - 0.0001) { 32 | $additionalText = sprintf('(-%.2f%%)', ($previousCoverage - $coverage)*100); 33 | $style .= 'background-color: #ff6666; color: white'; 34 | } 35 | 36 | 37 | // Note: there is a failure in the way Gitlab escapes HTML for the tables. Let's use this!. 38 | $this->msg .= sprintf(' 39 | 40 | 41 | 42 | 43 | 44 | 45 |
PHP code coverage:%.2f%%%s

', $coverageDetector->getCoveragePercentage()*100, $style, $additionalText); 46 | } 47 | 48 | 49 | public function addDifferencesHtml(CrapMethodFetcherInterface $methodFetcher, CrapMethodFetcherInterface $previousMethodFetcher, DiffService $diffService, string $commitId, string $gitlabUrl, string $projectName) 50 | { 51 | $differences = $diffService->getMeaningfulDifferences($methodFetcher, $previousMethodFetcher); 52 | 53 | $this->msg .= $this->getDifferencesHtml($differences, $commitId, $gitlabUrl, $projectName); 54 | } 55 | 56 | public function addDifference(Difference $difference, string $commitId, string $gitlabUrl, string $projectName) 57 | { 58 | $this->msg .= $this->getDifferencesHtml([$difference], $commitId, $gitlabUrl, $projectName); 59 | } 60 | 61 | 62 | private function getDifferencesHtml(array $differences, string $commitId, string $gitlabUrl, string $projectName) : string 63 | { 64 | if (empty($differences)) { 65 | return "No meaningful differences in code complexity detected.\n"; 66 | } 67 | 68 | $tableTemplate = ' 69 | 70 | 71 | 72 | 73 | 74 | 75 | %s 76 |
C.R.A.P.Variation
'; 77 | $tableRows = ''; 78 | $crapScoreEmojiGenerator = EmojiGenerator::createCrapScoreEmojiGenerator(); 79 | foreach ($differences as $difference) { 80 | $style = ''; 81 | if (!$difference->isNew()) { 82 | 83 | if ($difference->getCrapDifference() < 0) { 84 | $style = 'background-color: #00994c; color: white'; 85 | } else { 86 | $style = 'background-color: #ff6666; color: white'; 87 | } 88 | $differenceCol = sprintf('%+d', $difference->getCrapDifference()); 89 | $crapScoreEmoji = ''; 90 | } else { 91 | $differenceCol = 'New'; 92 | // For new rows, let's display an emoji 93 | $crapScoreEmoji = ' '.$crapScoreEmojiGenerator->getEmoji($difference->getCrapScore()); 94 | } 95 | 96 | if ($difference->getFile() !== null) { 97 | $link = $this->getLinkToMethodInCommit($gitlabUrl, $projectName, $commitId, $difference->getFile(), $difference->getLine()); 98 | $fullLink = sprintf('%s', $link, $difference->getMethodShortName()); 99 | } else { 100 | $fullLink = $difference->getMethodShortName(); 101 | } 102 | 103 | $tableRows .= sprintf(' 104 | %s 105 | %d%s 106 | %s 107 | ', $fullLink, $difference->getCrapScore(), $crapScoreEmoji, $style, $differenceCol); 108 | } 109 | 110 | return sprintf($tableTemplate, $tableRows); 111 | } 112 | 113 | private function getLinkToMethodInCommit(string $gitlabUrl, string $projectName, string $commit, string $filePath, int $line) 114 | { 115 | return rtrim($gitlabUrl, '/').'/'.$projectName.'/blob/'.$commit.'/'.ltrim($filePath, '/').'#L'.$line; 116 | } 117 | 118 | public function addFile(\SplFileInfo $file, string $gitlabUrl, string $projectName, string $buildId) 119 | { 120 | list($text, $isComplete) = $this->getFirstLines($file, self::MAX_NB_LINES_PER_FILE); 121 | 122 | $text = str_replace('```', '\\```', $text); 123 | $text = str_replace('~~~', '\\~~~', $text); 124 | 125 | $url = $this->getArtifactFileUrl($file->getFilename(), $gitlabUrl, $projectName, $buildId); 126 | 127 | $this->msg .= sprintf("\n[%s](%s)\n", $file->getFilename(), $url); 128 | $this->msg .= sprintf("```\n%s%s```\n", $text, $isComplete?'':"... (file truncated)\n"); 129 | 130 | if (!$isComplete) { 131 | $this->msg .= sprintf("[Download complete file](%s)\n", $url); 132 | } 133 | } 134 | 135 | private function getArtifactFileUrl(string $fileName, string $gitlabUrl, string $projectName, int $buildId) : string 136 | { 137 | return $gitlabUrl.'/'.$projectName.'/builds/'.$buildId.'/artifacts/file/'.$fileName; 138 | } 139 | 140 | /** 141 | * Returns the first lines of a file 142 | * 143 | * @param \SplFileInfo $file 144 | * @param int $maxLines 145 | * @return array First element: the string, second element: whether the returned string represents the whole file or not. 146 | */ 147 | private function getFirstLines(\SplFileInfo $file, int $maxLines) : array 148 | { 149 | // Let's get 50 lines at most. 150 | $cnt = $maxLines; 151 | $fileObject = $file->openFile(); 152 | $str = ''; 153 | while (!$fileObject->eof() && $cnt !== 0) { 154 | $str .= $fileObject->fgets(); 155 | $cnt--; 156 | } 157 | 158 | return [$str, $cnt !== 0]; 159 | } 160 | 161 | public function __toString() 162 | { 163 | return $this->msg; 164 | } 165 | 166 | } -------------------------------------------------------------------------------- /src/Commands/Config.php: -------------------------------------------------------------------------------- 1 | input = $input; 23 | } 24 | 25 | public function getCloverFilePath() : string 26 | { 27 | return $this->input->getOption('clover'); 28 | } 29 | 30 | public function getCrap4JFilePath() : string 31 | { 32 | return $this->input->getOption('crap4j'); 33 | } 34 | 35 | public function getGitlabApiToken() : string 36 | { 37 | $gitlabApiToken = $this->input->getOption('gitlab-api-token'); 38 | if ($gitlabApiToken === null) { 39 | $gitlabApiToken = getenv('GITLAB_API_TOKEN'); 40 | if ($gitlabApiToken === false) { 41 | throw new \RuntimeException('Could not find the Gitlab API token in the "GITLAB_API_TOKEN" environment variable. Either set this environment variable or pass the token via the --gitlab-api-token command line option.'); 42 | } 43 | } 44 | return $gitlabApiToken; 45 | } 46 | 47 | public function getGitlabUrl() : string 48 | { 49 | $gitlabUrl = $this->input->getOption('gitlab-url'); 50 | if ($gitlabUrl === null) { 51 | $ciProjectUrl = getenv('CI_REPOSITORY_URL'); 52 | if ($ciProjectUrl === false) { 53 | throw new \RuntimeException('Could not find the Gitlab URL in the "CI_REPOSITORY_URL" environment variable (usually set by Gitlab CI). Either set this environment variable or pass the URL via the --gitlab-url command line option.'); 54 | } 55 | $parsed_url = parse_url($ciProjectUrl); 56 | $scheme = isset($parsed_url['scheme']) ? $parsed_url['scheme'] . '://' : ''; 57 | $host = isset($parsed_url['host']) ? $parsed_url['host'] : ''; 58 | $port = isset($parsed_url['port']) ? ':' . $parsed_url['port'] : ''; 59 | $gitlabUrl = $scheme.$host.$port; 60 | } 61 | return rtrim($gitlabUrl, '/'); 62 | } 63 | 64 | public function getGitlabApiUrl() : string 65 | { 66 | return $this->getGitlabUrl().'/api/v3/'; 67 | } 68 | 69 | public function getGitlabProjectName() : string 70 | { 71 | $projectName = $this->input->getOption('gitlab-project-name'); 72 | if ($projectName === null) { 73 | $projectDir = getenv('CI_PROJECT_DIR'); 74 | if ($projectDir === false) { 75 | throw new \RuntimeException('Could not find the Gitlab project name in the "CI_PROJECT_DIR" environment variable (usually set by Gitlab CI). Either set this environment variable or pass the project name via the --gitlab-project-name command line option.'); 76 | } 77 | $projectName = substr($projectDir, 8); 78 | } 79 | return $projectName; 80 | } 81 | 82 | public function getCommitSha() : string 83 | { 84 | $commitSha = $this->input->getOption('commit-sha'); 85 | 86 | if ($commitSha === null) { 87 | $commitSha = getenv('CI_COMMIT_SHA'); 88 | if ($commitSha === false) { 89 | throw new \RuntimeException('Could not find the Gitlab build reference in the "CI_COMMIT_SHA" environment variable (usually set by Gitlab CI). Either set this environment variable or pass the build reference via the --commit-sha command line option.'); 90 | } 91 | } 92 | 93 | return $commitSha; 94 | } 95 | 96 | public function getJobStage() : string 97 | { 98 | $commitSha = $this->input->getOption('job-stage'); 99 | 100 | if ($commitSha === null) { 101 | $commitSha = getenv('CI_JOB_STAGE'); 102 | if ($commitSha === false) { 103 | throw new \RuntimeException('Could not find the Gitlab job stage in the "CI_JOB_STAGE" environment variable (usually set by Gitlab CI). Either set this environment variable or pass the job stage via the --job_stage command line option.'); 104 | } 105 | } 106 | 107 | return $commitSha; 108 | } 109 | 110 | public function getGitlabJobId() : int 111 | { 112 | $buildId = $this->input->getOption('gitlab-job-id'); 113 | if ($buildId === null) { 114 | $buildId = getenv('CI_JOB_ID'); 115 | if ($buildId === false) { 116 | throw new \RuntimeException('Could not find the Gitlab build id in the "CI_JOB_ID" environment variable (usually set by Gitlab CI). Either set this environment variable or pass the build id via the --gitlab-job-id command line option.'); 117 | } 118 | } 119 | return $buildId; 120 | } 121 | 122 | public function getGitlabBuildName() : string 123 | { 124 | $buildName = $this->input->getOption('gitlab-build-name'); 125 | if ($buildName === null) { 126 | $buildName = getenv('CI_BUILD_NAME'); 127 | if ($buildName === false) { 128 | throw new \RuntimeException('Could not find the Gitlab build name in the "CI_BUILD_NAME" environment variable (usually set by Gitlab CI). Either set this environment variable or pass the build id via the --gitlab-build-name command line option.'); 129 | } 130 | } 131 | return $buildName; 132 | } 133 | 134 | /** 135 | * Returns the current branch name (from Git) 136 | * @return string 137 | */ 138 | public function getCurrentBranchName() : string 139 | { 140 | // Gitlab 9+ 141 | $branchName = getenv('CI_COMMIT_REF_NAME'); 142 | if ($branchName !== false) { 143 | return $branchName; 144 | } 145 | 146 | $repo = new GitRepository(getcwd()); 147 | return $repo->getCurrentBranchName(); 148 | } 149 | 150 | public function getFiles() : array 151 | { 152 | return $this->input->getOption('file'); 153 | } 154 | 155 | public function isOpenIssue() : bool 156 | { 157 | return $this->input->getOption('open-issue'); 158 | } 159 | 160 | public function isAddCommentsInCommits() : bool 161 | { 162 | return $this->input->getOption('add-comments-in-commits'); 163 | } 164 | 165 | public function getGitlabPipelineId() : int 166 | { 167 | $buildName = $this->input->getOption('gitlab-pipeline-id'); 168 | if ($buildName === null) { 169 | $buildName = getenv('CI_PIPELINE_ID'); 170 | if ($buildName === false) { 171 | throw new \RuntimeException('Could not find the Gitlab pipeline ID in the "CI_PIPELINE_ID" environment variable (usually set by Gitlab CI). Either set this environment variable or pass the build id via the --gitlab-pipeline-id command line option.'); 172 | } 173 | } 174 | return $buildName; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Gitlab/BuildService.php: -------------------------------------------------------------------------------- 1 | client = $client; 27 | $this->logger = $logger; 28 | } 29 | 30 | /** 31 | * @param string $projectName 32 | * @param string $commitSha 33 | * @return array The merge request object 34 | * @throws MergeRequestNotFoundException 35 | */ 36 | public function findMergeRequestByCommitSha(string $projectName, string $commitSha) : array 37 | { 38 | // Find in the merge requests (since our build was triggered recently, it should definitely be there) 39 | /*$mergeRequests = $this->client->merge_requests->all($projectName, [ 40 | 'order_by' => 'updated_at', 41 | 'sort' => 'desc' 42 | ]);*/ 43 | 44 | $pager = new ResultPager($this->client); 45 | $mergeRequests = $pager->fetch($this->client->api('merge_requests'), 'all', [ 46 | $projectName, [ 47 | 'order_by' => 'updated_at', 48 | 'sort' => 'desc' 49 | ] 50 | ]); 51 | do { 52 | $this->logger->debug('Called API, got '.count($mergeRequests).' merge requests'); 53 | foreach ($mergeRequests as $mergeRequest) { 54 | // Let's only return this PR if the returned commit is the FIRST one (otherwise, the commit ID is on an outdated version of the PR) 55 | 56 | if ($mergeRequest['sha'] === $commitSha) { 57 | return $mergeRequest; 58 | } 59 | } 60 | 61 | if (!$pager->hasNext()) { 62 | break; 63 | } 64 | $mergeRequests = $pager->fetchNext(); 65 | } while (true); 66 | 67 | throw new MergeRequestNotFoundException('Could not find a PR whose last commit/buildRef ID is '.$commitSha); 68 | } 69 | 70 | public function getLatestCommitIdFromBranch(string $projectName, string $branchName) : string 71 | { 72 | $branch = $this->client->repositories->branch($projectName, $branchName); 73 | return $branch['commit']['id']; 74 | } 75 | 76 | private $pipelines = []; 77 | 78 | private function getPipelines(string $projectName) : array 79 | { 80 | if (!isset($this->pipelines[$projectName])) { 81 | $pager = new ResultPager($this->client); 82 | $this->pipelines[$projectName] = $pager->fetchAll($this->client->api('projects'), 'pipelines', 83 | [ $projectName ] 84 | ); 85 | } 86 | return $this->pipelines[$projectName]; 87 | } 88 | 89 | public function findPipelineByCommit(string $projectName, string $commitId) : ?array 90 | { 91 | $pipelines = $this->getPipelines($projectName); 92 | $this->logger->debug('Analysing '.count($pipelines).' pipelines to find pipeline for commit '.$commitId); 93 | 94 | foreach ($pipelines as $pipeline) { 95 | if ($pipeline['sha'] === $commitId) { 96 | return $pipeline; 97 | } 98 | } 99 | 100 | return null; 101 | } 102 | 103 | /** 104 | * Recursive function that attempts to find a build in the previous commits. 105 | * 106 | * @param string $projectName 107 | * @param string $commitId 108 | * @param string|null $excludePipelineId A pipeline ID we want to exclude (we don't want to get the current pipeline ID). 109 | * @param int $numIter 110 | * @return array 111 | * @throws BuildNotFoundException 112 | */ 113 | public function getLatestPipelineFromCommitId(string $projectName, string $commitId, string $excludePipelineId = null, int $numIter = 0) : array 114 | { 115 | $this->logger->debug('Looking for latest pipeline for commit '.$commitId); 116 | $pipeline = $this->findPipelineByCommit($projectName, $commitId); 117 | 118 | if ($pipeline !== null && $pipeline['id'] !== $excludePipelineId) { 119 | if ($pipeline['id'] !== $excludePipelineId) { 120 | $this->logger->debug('Found pipeline '.$pipeline['id'].' for commit '.$commitId); 121 | return $pipeline; 122 | } else { 123 | $this->logger->debug('Ignoring pipeline '.$excludePipelineId.' for commit '.$commitId); 124 | } 125 | } 126 | 127 | $numIter++; 128 | // Let's find a build in the last 10 commits. 129 | if ($numIter > 10) { 130 | $this->logger->debug('Could not find a build for commit '.$projectName.':'.$commitId.', after iterating on 10 parent commits.'); 131 | throw new BuildNotFoundException('Could not find a build for commit '.$projectName.':'.$commitId); 132 | } 133 | $this->logger->debug('Could not find a build for commit '.$projectName.':'.$commitId.'. Looking for a build in parent commit.'); 134 | 135 | // Let's get the commit info 136 | $commit = $this->client->repositories->commit($projectName, $commitId); 137 | $parentIds = $commit['parent_ids']; 138 | 139 | if (count($parentIds) !== 1) { 140 | $this->logger->debug('Cannot look into parent commit because it is a merge from 2 branches.'); 141 | throw new BuildNotFoundException('Could not find a build for commit '.$projectName.':'.$commitId); 142 | } 143 | 144 | // Not found? Let's recurse. 145 | return $this->getLatestPipelineFromCommitId($projectName, $parentIds[0], $excludePipelineId, $numIter); 146 | } 147 | 148 | /** 149 | * @param string $projectName 150 | * @param string $branchName 151 | * @param string $excludePipelineId A pipeline ID we want to exclude (we don't want to get the current pipeline ID). 152 | * @return array 153 | * @throws BuildNotFoundException 154 | */ 155 | public function getLatestPipelineFromBranch(string $projectName, string $branchName, string $excludePipelineId) : array 156 | { 157 | $commitId = $this->getLatestCommitIdFromBranch($projectName, $branchName); 158 | 159 | try { 160 | return $this->getLatestPipelineFromCommitId($projectName, $commitId, $excludePipelineId); 161 | } catch (BuildNotFoundException $e) { 162 | throw new BuildNotFoundException('Could not find a build for branch '.$projectName.':'.$branchName, 0, $e); 163 | } 164 | } 165 | 166 | /** 167 | * @param string $projectName 168 | * @param string $pipelineId 169 | * @param string $buildName 170 | * @param string $jobStage 171 | * @param string $file 172 | * @throws BuildNotFoundException 173 | */ 174 | public function dumpArtifact(string $projectName, string $pipelineId, string $buildName, string $jobStage, string $file) 175 | { 176 | // Call seems broken 177 | //$artifactContent = $this->client->jobs->artifactsByRefName($projectName, $buildRef, $jobName); 178 | 179 | $jobs = $this->client->jobs->pipelineJobs($projectName, $pipelineId); 180 | $job = null; 181 | foreach ($jobs as $jobItem) { 182 | if ($jobItem['name'] === $buildName && 183 | $jobItem['stage'] === $jobStage && 184 | isset($jobItem['artifacts_file']) && 185 | (in_array($jobItem['status'], ['failed', 'success'])) 186 | ) { 187 | $job = $jobItem; 188 | break; 189 | } 190 | } 191 | 192 | if ($job === null) { 193 | throw new BuildNotFoundException('Could not find finished job with build name "'.$buildName.'", stage "'.$jobStage.'" and artifacts file in pipeline "'.$pipelineId.'"'); 194 | } 195 | $this->logger->debug('Found job '. $job['id'] . ' for pipeline ' . $pipelineId); 196 | 197 | $artifactContent = $this->client->jobs->artifacts($projectName, $job['id']); 198 | 199 | $stream = StreamWrapper::getResource($artifactContent); 200 | 201 | $filesystem = new Filesystem(); 202 | $filesystem->dumpFile($file, $stream); 203 | } 204 | 205 | /** 206 | * @param string $projectName 207 | * @param string $branchName 208 | * @param string $buildName 209 | * @param string $jobStage 210 | * @param string $file 211 | * @param string $excludePipelineId A pipeline ID we want to exclude (we don't want to get the current pipeline ID). 212 | * @throws BuildNotFoundException 213 | */ 214 | public function dumpArtifactFromBranch(string $projectName, string $branchName, string $buildName, string $jobStage, string $file, string $excludePipelineId) 215 | { 216 | $pipeline = $this->getLatestPipelineFromBranch($projectName, $branchName, $excludePipelineId); 217 | $this->dumpArtifact($projectName, $pipeline['id'], $buildName, $jobStage, $file); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /tests/Commands/ConfigTest.php: -------------------------------------------------------------------------------- 1 | 'clovertest.xml'), $this->getInputDefinition()); 80 | 81 | $config = new Config($input); 82 | $this->assertSame('clovertest.xml', $config->getCloverFilePath()); 83 | } 84 | 85 | public function testGitlabApiTokenFromEnv() 86 | { 87 | putenv('GITLAB_API_TOKEN=DEADBEEF'); 88 | $input = new ArrayInput([], $this->getInputDefinition()); 89 | $config = new Config($input); 90 | $this->assertSame('DEADBEEF', $config->getGitlabApiToken()); 91 | } 92 | 93 | public function testGitlabApiTokenFromParam() 94 | { 95 | $input = new ArrayInput(array('--gitlab-api-token' => '1234'), $this->getInputDefinition()); 96 | 97 | $config = new Config($input); 98 | $this->assertSame('1234', $config->getGitlabApiToken()); 99 | } 100 | 101 | public function testNoGitlabApiToken() 102 | { 103 | putenv('GITLAB_API_TOKEN'); 104 | $input = new ArrayInput([], $this->getInputDefinition()); 105 | 106 | $config = new Config($input); 107 | $this->expectException(\RuntimeException::class); 108 | $config->getGitlabApiToken(); 109 | } 110 | 111 | public function testGitlabUrlFromEnv() 112 | { 113 | putenv('CI_REPOSITORY_URL=http://gitlab-ci-token:xxxxxx@git.example.com/mouf/test.git'); 114 | $input = new ArrayInput([], $this->getInputDefinition()); 115 | $config = new Config($input); 116 | $this->assertSame('http://git.example.com', $config->getGitlabUrl()); 117 | } 118 | 119 | public function testGitlabUrlFromParam() 120 | { 121 | $input = new ArrayInput(array('--gitlab-url' => 'http://git.example2.com/'), $this->getInputDefinition()); 122 | 123 | $config = new Config($input); 124 | $this->assertSame('http://git.example2.com', $config->getGitlabUrl()); 125 | } 126 | 127 | public function testGitlabApiUrlFromParam() 128 | { 129 | $input = new ArrayInput(array('--gitlab-url' => 'http://git.example2.com/'), $this->getInputDefinition()); 130 | 131 | $config = new Config($input); 132 | $this->assertSame('http://git.example2.com/api/v3/', $config->getGitlabApiUrl()); 133 | } 134 | 135 | public function testNoGitlabUrl() 136 | { 137 | putenv('CI_REPOSITORY_URL'); 138 | $input = new ArrayInput([], $this->getInputDefinition()); 139 | 140 | $config = new Config($input); 141 | $this->expectException(\RuntimeException::class); 142 | $config->getGitlabUrl(); 143 | } 144 | 145 | public function testGitlabProjectNameFromEnv() 146 | { 147 | putenv('CI_PROJECT_DIR=/builds/foo/bar'); 148 | $input = new ArrayInput([], $this->getInputDefinition()); 149 | $config = new Config($input); 150 | $this->assertSame('foo/bar', $config->getGitlabProjectName()); 151 | } 152 | 153 | public function testGitlabProjectNameFromParam() 154 | { 155 | $input = new ArrayInput(array('--gitlab-project-name' => 'foo/bar'), $this->getInputDefinition()); 156 | 157 | $config = new Config($input); 158 | $this->assertSame('foo/bar', $config->getGitlabProjectName()); 159 | } 160 | 161 | public function testNoGitlabProjectName() 162 | { 163 | putenv('CI_PROJECT_DIR'); 164 | $input = new ArrayInput([], $this->getInputDefinition()); 165 | 166 | $config = new Config($input); 167 | $this->expectException(\RuntimeException::class); 168 | $config->getGitlabProjectName(); 169 | } 170 | 171 | public function testGitlab9CommitShaFromEnv() 172 | { 173 | putenv('CI_COMMIT_SHA=DEADBEEFDEADBEEF'); 174 | $input = new ArrayInput([], $this->getInputDefinition()); 175 | $config = new Config($input); 176 | $this->assertSame('DEADBEEFDEADBEEF', $config->getCommitSha()); 177 | } 178 | 179 | public function testGitlabCommitShaFromParam() 180 | { 181 | putenv('CI_COMMIT_SHA'); 182 | $input = new ArrayInput(array('--commit-sha' => 'DEADBEEFDEADBEEF2'), $this->getInputDefinition()); 183 | 184 | $config = new Config($input); 185 | $this->assertSame('DEADBEEFDEADBEEF2', $config->getCommitSha()); 186 | } 187 | 188 | public function testNoGitlabCommitSha() 189 | { 190 | putenv('CI_COMMIT_SHA'); 191 | $input = new ArrayInput([], $this->getInputDefinition()); 192 | 193 | $config = new Config($input); 194 | $this->expectException(\RuntimeException::class); 195 | $config->getCommitSha(); 196 | } 197 | 198 | public function testGitlabJobIdFromEnv() 199 | { 200 | putenv('CI_JOB_ID=42'); 201 | $input = new ArrayInput([], $this->getInputDefinition()); 202 | $config = new Config($input); 203 | $this->assertSame(42, $config->getGitlabJobId()); 204 | } 205 | 206 | public function testGitlabJobIdFromParam() 207 | { 208 | putenv('CI_JOB_ID'); 209 | 210 | $input = new ArrayInput(array('--gitlab-job-id' => '42'), $this->getInputDefinition()); 211 | 212 | $config = new Config($input); 213 | $this->assertSame(42, $config->getGitlabJobId()); 214 | } 215 | 216 | public function testNoGitlabJobId() 217 | { 218 | $input = new ArrayInput([], $this->getInputDefinition()); 219 | 220 | $config = new Config($input); 221 | $this->expectException(\RuntimeException::class); 222 | $config->getGitlabJobId(); 223 | } 224 | 225 | public function testGitlabBuildNameFromEnv() 226 | { 227 | putenv('CI_BUILD_NAME=foo'); 228 | $input = new ArrayInput([], $this->getInputDefinition()); 229 | $config = new Config($input); 230 | $this->assertSame('foo', $config->getGitlabBuildName()); 231 | } 232 | 233 | public function testGitlabBuildNameFromParam() 234 | { 235 | putenv('CI_BUILD_NAME'); 236 | 237 | $input = new ArrayInput(array('--gitlab-build-name' => 'foo'), $this->getInputDefinition()); 238 | 239 | $config = new Config($input); 240 | $this->assertSame('foo', $config->getGitlabBuildName()); 241 | } 242 | 243 | public function testNoGitlabBuildName() 244 | { 245 | $input = new ArrayInput([], $this->getInputDefinition()); 246 | 247 | $config = new Config($input); 248 | $this->expectException(\RuntimeException::class); 249 | $config->getGitlabBuildName(); 250 | } 251 | 252 | public function testGitlabPipelineIdFromEnv() 253 | { 254 | putenv('CI_PIPELINE_ID=42'); 255 | $input = new ArrayInput([], $this->getInputDefinition()); 256 | $config = new Config($input); 257 | $this->assertSame(42, $config->getGitlabPipelineId()); 258 | } 259 | 260 | public function testGitlabPipelineIdFromParam() 261 | { 262 | putenv('CI_PIPELINE_ID'); 263 | 264 | $input = new ArrayInput(array('--gitlab-pipeline-id' => 42), $this->getInputDefinition()); 265 | 266 | $config = new Config($input); 267 | $this->assertSame(42, $config->getGitlabPipelineId()); 268 | } 269 | 270 | public function testNoGitlabPipelineId() 271 | { 272 | $input = new ArrayInput([], $this->getInputDefinition()); 273 | 274 | $config = new Config($input); 275 | $this->expectException(\RuntimeException::class); 276 | $config->getGitlabPipelineId(); 277 | } 278 | 279 | public function testGitlabJobStageFromParam() 280 | { 281 | putenv('CI_JOB_STAGE'); 282 | 283 | $input = new ArrayInput(array('--job-stage' => 'test'), $this->getInputDefinition()); 284 | 285 | $config = new Config($input); 286 | $this->assertSame('test', $config->getJobStage()); 287 | } 288 | 289 | public function testGitlabJobStageFromEnv() 290 | { 291 | putenv('CI_JOB_STAGE=test2'); 292 | 293 | $input = new ArrayInput([], $this->getInputDefinition()); 294 | 295 | $config = new Config($input); 296 | $this->assertSame('test2', $config->getJobStage()); 297 | } 298 | 299 | public function testNoGitlabJobStage() 300 | { 301 | putenv('CI_JOB_STAGE'); 302 | 303 | $input = new ArrayInput([], $this->getInputDefinition()); 304 | 305 | $config = new Config($input); 306 | $this->expectException(\RuntimeException::class); 307 | $this->assertSame('test2', $config->getJobStage()); 308 | } 309 | 310 | public function testFile() 311 | { 312 | $input = new ArrayInput(array('--file' => ['foo.txt']), $this->getInputDefinition()); 313 | 314 | $config = new Config($input); 315 | $this->assertSame(['foo.txt'], $config->getFiles()); 316 | } 317 | 318 | public function testOpenIssueTrue() 319 | { 320 | $input = new ArrayInput(array('--open-issue' => true), $this->getInputDefinition()); 321 | 322 | $config = new Config($input); 323 | $this->assertTrue($config->isOpenIssue()); 324 | } 325 | 326 | public function testOpenIssueFalse() 327 | { 328 | $input = new ArrayInput(array(), $this->getInputDefinition()); 329 | 330 | $config = new Config($input); 331 | $this->assertFalse($config->isOpenIssue()); 332 | } 333 | 334 | public function testAddCommentsInCommitsTrue() 335 | { 336 | $input = new ArrayInput(array('--add-comments-in-commits' => true), $this->getInputDefinition()); 337 | 338 | $config = new Config($input); 339 | $this->assertTrue($config->isAddCommentsInCommits()); 340 | } 341 | 342 | public function testAddCommentsInCommitsFalse() 343 | { 344 | $input = new ArrayInput(array(), $this->getInputDefinition()); 345 | 346 | $config = new Config($input); 347 | $this->assertFalse($config->isAddCommentsInCommits()); 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/Commands/RunCommand.php: -------------------------------------------------------------------------------- 1 | setName('run') 35 | ->setDescription('Analyses the coverage report files and upload the result to Gitlab') 36 | //->setHelp("This command allows you to create users...") 37 | ->addOption('clover', 38 | 'c', 39 | InputOption::VALUE_REQUIRED, 40 | 'The path to the clover.xml file generated by PHPUnit.', 41 | 'clover.xml') 42 | ->addOption('crap4j', 43 | 'j', 44 | InputOption::VALUE_REQUIRED, 45 | 'The path to the crap4j.xml file generated by PHPUnit.', 46 | 'crap4j.xml') 47 | ->addOption('gitlab-url', 48 | 'u', 49 | InputOption::VALUE_REQUIRED, 50 | 'The Gitlab URL. If not specified, it is deduced from the CI_REPOSITORY_URL environment variable.', 51 | null) 52 | ->addOption('gitlab-api-token', 53 | 't', 54 | InputOption::VALUE_REQUIRED, 55 | 'The Gitlab API token. If not specified, it is fetched from the GITLAB_API_TOKEN environment variable.', 56 | null) 57 | /*->addOption('gitlab-project-id', 58 | 'p', 59 | InputOption::VALUE_REQUIRED, 60 | 'The Gitlab project ID. If not specified, it is fetched from the CI_PROJECT_ID environment variable.', 61 | null)*/ 62 | ->addOption('gitlab-project-name', 63 | 'p', 64 | InputOption::VALUE_REQUIRED, 65 | 'The Gitlab project name (in the form "group/name"). If not specified, it is deduced from the CI_PROJECT_DIR environment variable.', 66 | null) 67 | ->addOption('commit-sha', 68 | 'r', 69 | InputOption::VALUE_REQUIRED, 70 | 'The commit SHA. If not specified, it is deduced from the CI_COMMIT_SHA environment variable.', 71 | null) 72 | ->addOption('gitlab-job-id', 73 | 'b', 74 | InputOption::VALUE_REQUIRED, 75 | 'The Gitlab CI build/job id. If not specified, it is deduced from the CI_JOB_ID environment variable.', 76 | null) 77 | ->addOption('gitlab-build-name', 78 | 'a', 79 | InputOption::VALUE_REQUIRED, 80 | 'The Gitlab CI build name (the name of this build in the job). If not specified, it is deduced from the CI_BUILD_NAME environment variable.', 81 | null) 82 | ->addOption('gitlab-pipeline-id', 83 | 'e', 84 | InputOption::VALUE_REQUIRED, 85 | 'The Gitlab CI pipeline ID. If not specified, it is deduced from the CI_PIPELINE_ID environment variable.', 86 | null) 87 | ->addOption('job-stage', 88 | 's', 89 | InputOption::VALUE_REQUIRED, 90 | 'The Gitlab CI job stage. If not specified, it is deduced from the CI_JOB_STAGE environment variable.', 91 | null) 92 | ->addOption('file', 93 | 'f', 94 | InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 95 | 'Text file to be sent in the merge request comments (can be used multiple times).', 96 | []) 97 | ->addOption('open-issue', 98 | 'i', 99 | InputOption::VALUE_NONE, 100 | 'Opens an issue (if the build is not part of a merge request)') 101 | ->addOption('add-comments-in-commits', 102 | null, 103 | InputOption::VALUE_NONE, 104 | 'Add comments directly in the commit') 105 | ; 106 | } 107 | 108 | protected function execute(InputInterface $input, OutputInterface $output) 109 | { 110 | $this->logger = new ConsoleLogger($output); 111 | $config = new Config($input); 112 | 113 | $cloverFilePath = $config->getCloverFilePath(); 114 | 115 | $cloverFile = null; 116 | if (file_exists($cloverFilePath)) { 117 | $cloverFile = CloverFile::fromFile($cloverFilePath, getcwd()); 118 | //$output->writeln(sprintf('Code coverage: %.2f%%', $cloverFile->getCoveragePercentage() * 100)); 119 | } 120 | 121 | $crap4JFilePath = $config->getCrap4JFilePath(); 122 | 123 | $crap4jFile = null; 124 | if (file_exists($crap4JFilePath)) { 125 | $crap4jFile = Crap4JFile::fromFile($crap4JFilePath); 126 | } 127 | 128 | $files = $config->getFiles(); 129 | 130 | $methodsProvider = null; 131 | $codeCoverageProvider = null; 132 | 133 | if ($cloverFile !== null && $crap4jFile !== null) { 134 | $methodsProvider = new CrapMethodMerger($cloverFile, $crap4jFile); 135 | $codeCoverageProvider = $cloverFile; 136 | } elseif ($cloverFile !== null) { 137 | $methodsProvider = $cloverFile; 138 | $codeCoverageProvider = $cloverFile; 139 | } elseif ($crap4jFile !== null) { 140 | $methodsProvider = $crap4jFile; 141 | } elseif (empty($files)) { 142 | $output->writeln('Could not find neither clover file, nor crap4j file for analysis nor files to send in comments. Nothing done. Searched paths: '.$cloverFilePath.' and '.$crap4JFilePath.'.'); 143 | $output->writeln('You might want to check that your unit tests where run successfully'); 144 | $output->writeln('Alternatively, maybe you did not run Clover file generation? Use "phpunit --coverage-clover clover.xml"'); 145 | return; 146 | } 147 | 148 | $gitlabApiToken = $config->getGitlabApiToken(); 149 | 150 | $gitlabUrl = $config->getGitlabUrl(); 151 | $gitlabApiUrl = $config->getGitlabApiUrl(); 152 | 153 | 154 | /*$projectId = $input->getOption('gitlab-project-id'); 155 | if ($projectId === null) { 156 | $projectId = getenv('CI_PROJECT_ID'); 157 | if ($projectId === false) { 158 | throw new \RuntimeException('Could not find the Gitlab project ID in the "CI_PROJECT_ID" environment variable (usually set by Gitlab CI). Either set this environment variable or pass the ID via the --gitlab-project-id command line option.'); 159 | } 160 | }*/ 161 | 162 | $projectName = $config->getGitlabProjectName(); 163 | 164 | $commitSha = $config->getCommitSha(); 165 | 166 | $currentBranchName = $config->getCurrentBranchName(); 167 | 168 | $client = Client::create($gitlabApiUrl); 169 | $client->authenticate($gitlabApiToken); 170 | 171 | $diffService = new DiffService(5, 30, 20); 172 | 173 | $sendCommentService = new SendCommentService($client, $diffService); 174 | 175 | // From CI_COMMIT_SHA, we can get the commit ( -> project -> build -> commit ) 176 | // From the merge_requests API, we can get the list of commits for a single merge request 177 | // Hence, we can find the merge_request matching a build! 178 | 179 | $buildService = new BuildService($client, $this->logger); 180 | 181 | $inMergeRequest = false; 182 | 183 | try { 184 | $mergeRequest = $buildService->findMergeRequestByCommitSha($projectName, $commitSha); 185 | 186 | $repo = new GitRepository(getcwd()); 187 | $targetCommit = $repo->getLatestCommitForBranch('origin/'.$mergeRequest['target_branch']); 188 | $lastCommonCommit = $repo->getMergeBase($targetCommit, $commitSha); 189 | 190 | $output->writeln('Pipeline current commit: '.$commitSha, OutputInterface::VERBOSITY_DEBUG); 191 | $output->writeln('Target branch: '.$mergeRequest['target_branch'], OutputInterface::VERBOSITY_DEBUG); 192 | $output->writeln('Target commit: '.$targetCommit, OutputInterface::VERBOSITY_DEBUG); 193 | $output->writeln('Last common commit: '.$lastCommonCommit, OutputInterface::VERBOSITY_DEBUG); 194 | 195 | list($previousCodeCoverageProvider, $previousMethodsProvider) = $this->getMeasuresFromCommit($buildService, $mergeRequest['target_project_id'], $lastCommonCommit, $cloverFilePath, $crap4JFilePath, $config->getJobStage(), $config->getGitlabBuildName(), null); 196 | //list($previousCodeCoverageProvider, $previousMethodsProvider) = $this->getMeasuresFromBranch($buildService, $mergeRequest['target_project_id'], $mergeRequest['target_branch'], $cloverFilePath, $crap4JFilePath); 197 | 198 | $message = new Message(); 199 | if ($codeCoverageProvider !== null) { 200 | $message->addCoverageMessage($codeCoverageProvider, $previousCodeCoverageProvider); 201 | } else { 202 | $output->writeln('Could not find clover file for code coverage analysis.'); 203 | } 204 | if ($methodsProvider !== null) { 205 | $message->addDifferencesHtml($methodsProvider, $previousMethodsProvider, $diffService, $commitSha, $gitlabUrl, $projectName); 206 | } else { 207 | $output->writeln('Could not find clover file nor crap4j file for CRAP score analysis.'); 208 | } 209 | 210 | $this->addFilesToMessage($message, $files, $output, $config); 211 | 212 | $client->merge_requests->addNote($projectName, $mergeRequest['iid'], (string) $message); 213 | 214 | $inMergeRequest = true; 215 | } catch (MergeRequestNotFoundException $e) { 216 | // If there is no merge request attached to this build, let's skip the merge request comment. We can still make some comments on the commit itself! 217 | $output->writeln('It seems that this CI build is not part of a merge request.'); 218 | } 219 | 220 | if ($config->isAddCommentsInCommits() || ($config->isOpenIssue() && !$inMergeRequest)) { 221 | try { 222 | $targetProjectId = $mergeRequest['target_project_id'] ?? $projectName; 223 | list($lastCommitCoverage, $lastCommitMethodsProvider) = $this->getMeasuresFromBranch($buildService, $targetProjectId, $currentBranchName, $cloverFilePath, $crap4JFilePath, $config->getJobStage(), $config->getGitlabBuildName(), $config->getGitlabPipelineId()); 224 | 225 | if ($config->isAddCommentsInCommits()) { 226 | $sendCommentService->sendDifferencesCommentsInCommit($methodsProvider, $lastCommitMethodsProvider, $projectName, $commitSha, $gitlabUrl); 227 | } 228 | 229 | if ($config->isOpenIssue() && !$inMergeRequest) { 230 | $message = new Message(); 231 | 232 | if ($codeCoverageProvider !== null) { 233 | $message->addCoverageMessage($codeCoverageProvider, $lastCommitCoverage); 234 | } else { 235 | $output->writeln('Could not find clover file for code coverage analysis.'); 236 | } 237 | 238 | if ($methodsProvider !== null) { 239 | $message->addDifferencesHtml($methodsProvider, $lastCommitMethodsProvider, $diffService, $commitSha, $gitlabUrl, $projectName); 240 | } else { 241 | $output->writeln('Could not find clover file nor crap4j file for CRAP score analysis.'); 242 | } 243 | 244 | $this->addFilesToMessage($message, $files, $output, $config); 245 | 246 | $project = new Project($projectName, $client); 247 | 248 | $options = [ 249 | 'description' => (string) $message 250 | ]; 251 | 252 | $userId = $this->getCommiterId($project, $commitSha); 253 | if ($userId !== null) { 254 | $options['assignee_id'] = $userId; 255 | } 256 | 257 | $project->createIssue('Build failed', $options); 258 | } 259 | } catch (BuildNotFoundException $e) { 260 | $output->writeln('Unable to find a previous build for this branch. Skipping adding comments inside the commit. '.$e->getMessage()); 261 | } 262 | } 263 | } 264 | 265 | /** 266 | * Returns the user id of the committer. 267 | * 268 | * @param Project $project 269 | * @param string $commitRef 270 | * @return int|null 271 | */ 272 | private function getCommiterId(Project $project, $commitRef) 273 | { 274 | 275 | $commit = $project->commit($commitRef); 276 | 277 | return $commit->committer ? $commit->committer->id : null; 278 | } 279 | 280 | /** 281 | * @param BuildService $buildService 282 | * @param string $projectName 283 | * @param string $targetBranch 284 | * @param string $cloverPath 285 | * @param string $crap4JPath 286 | * @return array First element: code coverage, second element: list of methods. 287 | * @throws BuildNotFoundException 288 | */ 289 | public function getMeasuresFromBranch(BuildService $buildService, string $projectName, string $targetBranch, string $cloverPath, string $crap4JPath, string $jobStage, string $buildName, string $excludePipelineId) : array 290 | { 291 | try { 292 | $tmpFile = tempnam(sys_get_temp_dir(), 'art').'.zip'; 293 | 294 | $buildService->dumpArtifactFromBranch($projectName, $targetBranch, $buildName, $jobStage, $tmpFile, $excludePipelineId); 295 | $zipFile = new \ZipArchive(); 296 | if ($zipFile->open($tmpFile)!==true) { 297 | throw new \RuntimeException('Invalid ZIP archive '.$tmpFile); 298 | } 299 | return $this->getMeasuresFromZipFile($zipFile, $cloverPath, $crap4JPath); 300 | } catch (\RuntimeException $e) { 301 | if ($e->getCode() === 404) { 302 | // We could not find a previous clover file in the master branch. 303 | // Maybe this branch is the first to contain clover files? 304 | // Let's deal with this by generating a fake "empty" clover file. 305 | $this->logger->info('We could not find a previous clover file in the build attached to branch '.$targetBranch.'. Maybe this commit is the first on this branch?'); 306 | return [EmptyCloverFile::create(), EmptyCloverFile::create()]; 307 | } else { 308 | throw $e; 309 | } 310 | } 311 | } 312 | 313 | public function getMeasuresFromCommit(BuildService $buildService, string $projectName, string $commitId, string $cloverPath, string $crap4JPath, string $jobStage, string $buildName, ?string $excludePipelineId) : array 314 | { 315 | try { 316 | $tmpFile = tempnam(sys_get_temp_dir(), 'art').'.zip'; 317 | 318 | $pipeline = $buildService->getLatestPipelineFromCommitId($projectName, $commitId, $excludePipelineId); 319 | $buildService->dumpArtifact($projectName, $pipeline['id'], $buildName, $jobStage, $tmpFile); 320 | $zipFile = new \ZipArchive(); 321 | $result = $zipFile->open($tmpFile); 322 | if ($result !== true) { 323 | switch ($result) { 324 | case 9: 325 | throw new \RuntimeException("An error occurred while unzipping artifact from commit $commitId. Error code $result: No such file"); 326 | case 11: 327 | throw new \RuntimeException("An error occurred while unzipping artifact from commit $commitId. Error code $result: Can't open file"); 328 | case 19: 329 | throw new \RuntimeException("An error occurred while unzipping artifact from commit $commitId. Error code $result: Not a zip archive"); 330 | default: 331 | throw new \RuntimeException("An error occurred while unzipping artifact from commit $commitId. Error code $result: ".$zipFile->getStatusString()); 332 | } 333 | } 334 | return $this->getMeasuresFromZipFile($zipFile, $cloverPath, $crap4JPath); 335 | } catch (\RuntimeException $e) { 336 | if ($e->getCode() === 404) { 337 | // We could not find a previous clover file in the given commit. 338 | // Maybe this branch is the first to contain clover files? 339 | // Let's deal with this by generating a fake "empty" clover file. 340 | $this->logger->warning('We could not find a previous clover file in the build attached to commit '.$commitId.'. Maybe this branch is the first to contain clover files?'); 341 | $this->logger->debug($e->getMessage().' - '.$e->getTraceAsString(), [ 342 | 'exception' => $e 343 | ]); 344 | return [EmptyCloverFile::create(), EmptyCloverFile::create()]; 345 | } else { 346 | throw $e; 347 | } 348 | } catch (BuildNotFoundException $e) { 349 | // Maybe there is no .gitlab-ci.yml file on the target branch? In this case, there is no build. 350 | $this->logger->warning('We could not find a build for commit '.$commitId.'.'); 351 | return [EmptyCloverFile::create(), EmptyCloverFile::create()]; 352 | } 353 | } 354 | 355 | private function getMeasuresFromZipFile(\ZipArchive $zipFile, string $cloverPath, string $crap4JPath) : array 356 | { 357 | $cloverFileString = $zipFile->getFromName($cloverPath); 358 | 359 | $cloverFile = null; 360 | if ($cloverFileString !== false) { 361 | $cloverFile = CloverFile::fromString($cloverFileString, getcwd()); 362 | } 363 | 364 | $crap4JString = $zipFile->getFromName($crap4JPath); 365 | 366 | $crap4JFile = null; 367 | if ($crap4JString !== false) { 368 | $crap4JFile = Crap4JFile::fromString($crap4JString); 369 | } 370 | 371 | $methodsProvider = null; 372 | $codeCoverageProvider = null; 373 | 374 | if ($cloverFile !== null && $crap4JFile !== null) { 375 | $methodsProvider = new CrapMethodMerger($cloverFile, $crap4JFile); 376 | $codeCoverageProvider = $cloverFile; 377 | } elseif ($cloverFile !== null) { 378 | $methodsProvider = $cloverFile; 379 | $codeCoverageProvider = $cloverFile; 380 | } elseif ($crap4JFile !== null) { 381 | $methodsProvider = $crap4JFile; 382 | } else { 383 | return [EmptyCloverFile::create(), EmptyCloverFile::create()]; 384 | } 385 | 386 | return [$codeCoverageProvider, $methodsProvider]; 387 | } 388 | 389 | private function addFilesToMessage(Message $message, array $files, OutputInterface $output, Config $config) { 390 | foreach ($files as $file) { 391 | if (!file_exists($file)) { 392 | $output->writeln('Could not find file to send "'.$file.'". Skipping this file.'); 393 | continue; 394 | } 395 | 396 | $message->addFile(new \SplFileInfo($file), $config->getGitlabUrl(), $config->getGitlabProjectName(), $config->getGitlabJobId()); 397 | } 398 | } 399 | } 400 | 401 | /* 402 | =================ENV IN A PR CONTEXT ========================= 403 | 404 | CI_BUILD_TOKEN=xxxxxx 405 | HOSTNAME=runner-9431b96d-project-428-concurrent-0 406 | PHP_INI_DIR=/usr/local/etc/php 407 | PHP_ASC_URL=https://secure.php.net/get/php-7.0.15.tar.xz.asc/from/this/mirror 408 | CI_BUILD_BEFORE_SHA=7af13f8e3bd090c7c34750e4badfc66a5f0af110 409 | CI_SERVER_VERSION= 410 | CI_BUILD_ID=109 411 | OLDPWD=/ 412 | PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 413 | PHP_MD5=dca23412f3e3b3987e582091b751925d 414 | CI_PROJECT_ID=428 415 | PHPIZE_DEPS=autoconf file g++ gcc libc-dev make pkg-config re2c 416 | PHP_URL=https://secure.php.net/get/php-7.0.15.tar.xz/from/this/mirror 417 | CI_BUILD_REF_NAME=feature/js-ci 418 | CI_BUILD_REF=7af13f8e3bd090c7c34750e4badfc66a5f0af110 419 | PHP_LDFLAGS=-Wl,-O1 -Wl,--hash-style=both -pie 420 | PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 421 | CI_BUILD_STAGE=test 422 | CI_PROJECT_DIR=/builds/tcm-projects/uneo 423 | PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 424 | GPG_KEYS=1A4E8B7277C42E53DBA9C7B9BCAA30EA9C0D5763 6E4F6AB321FDC07F2C332E3AC2BF0BC433CFC8B3 425 | PWD=/builds/tcm-projects/uneo 426 | CI_DEBUG_TRACE=false 427 | CI_SERVER_NAME=GitLab CI 428 | XDEBUG_VERSION=2.5.0 429 | GITLAB_CI=true 430 | CI_SERVER_REVISION= 431 | CI_BUILD_NAME=test:app 432 | HOME=/root 433 | SHLVL=1 434 | PHP_SHA256=300364d57fc4a6176ff7d52d390ee870ab6e30df121026649f8e7e0b9657fe93 435 | CI_SERVER=yes 436 | CI=true 437 | CI_BUILD_REPO=http://gitlab-ci-token:xxxxxx@git.thecodingmachine.com/tcm-projects/uneo.git 438 | PHP_VERSION=7.0.15 439 | 440 | ===================ENV IN A COMMIT CONTEXT 441 | 442 | CI_BUILD_TOKEN=xxxxxx 443 | HOSTNAME=runner-9431b96d-project-447-concurrent-0 444 | PHP_INI_DIR=/usr/local/etc/php 445 | PHP_ASC_URL=https://secure.php.net/get/php-7.0.15.tar.xz.asc/from/this/mirror 446 | CI_BUILD_BEFORE_SHA=42dd9686eafc2e8fb0a6b4d2c6785baec229c94a 447 | CI_SERVER_VERSION= 448 | CI_BUILD_ID=192 449 | OLDPWD=/ 450 | PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 451 | PHP_MD5=dca23412f3e3b3987e582091b751925d 452 | CI_PROJECT_ID=447 453 | GITLAB_API_TOKEN=xxxxxxxxxxxxxxchangedmanually 454 | PHPIZE_DEPS=autoconf file g++ gcc libc-dev make pkg-config re2c 455 | PHP_URL=https://secure.php.net/get/php-7.0.15.tar.xz/from/this/mirror 456 | CI_BUILD_REF_NAME=master 457 | CI_BUILD_REF=42dd9686eafc2e8fb0a6b4d2c6785baec229c94a 458 | PHP_LDFLAGS=-Wl,-O1 -Wl,--hash-style=both -pie 459 | PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 460 | CI_BUILD_STAGE=test 461 | CI_PROJECT_DIR=/builds/dan/washing-test 462 | PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 463 | GPG_KEYS=1A4E8B7277C42E53DBA9C7B9BCAA30EA9C0D5763 6E4F6AB321FDC07F2C332E3AC2BF0BC433CFC8B3 464 | PWD=/builds/dan/washing-test 465 | CI_DEBUG_TRACE=false 466 | CI_SERVER_NAME=GitLab CI 467 | XDEBUG_VERSION=2.5.0 468 | GITLAB_CI=true 469 | CI_SERVER_REVISION= 470 | CI_BUILD_NAME=test 471 | HOME=/root 472 | SHLVL=1 473 | PHP_SHA256=300364d57fc4a6176ff7d52d390ee870ab6e30df121026649f8e7e0b9657fe93 474 | CI_SERVER=yes 475 | CI=true 476 | CI_BUILD_REPO=http://gitlab-ci-token:xxxxxx@git.thecodingmachine.com/dan/washing-test.git 477 | PHP_VERSION=7.0.15 478 | */ 479 | --------------------------------------------------------------------------------