├── tests ├── fixtures │ ├── README.md │ ├── SqlHeredocFixture.php │ └── SqlNowdocFixture.php ├── configs │ ├── empty.php │ ├── warning.php │ └── all.php ├── Struct │ ├── TestFile.php │ ├── Local │ │ ├── LocalFileTest.php │ │ └── LocalPullRequestTest.php │ └── FileCollectionTest.php ├── Renderer │ └── HTMLRendererTest.php ├── ApplicationTest.php ├── ConfigLoaderTest.php ├── Rule │ ├── CheckPhpStanTest.php │ ├── MaxCommitTest.php │ ├── ConditionTest.php │ ├── CommitRegexTest.php │ ├── CheckPhpCsFixerTest.php │ └── DisallowRepeatedCommitsTest.php ├── RunnerTest.php ├── Platform │ ├── Github │ │ ├── payloads │ │ │ ├── reviews.json │ │ │ ├── commits.json │ │ │ ├── comments.json │ │ │ └── files.json │ │ └── GithubCommenterTest.php │ ├── Local │ │ └── LocalPlatformTest.php │ ├── PlatformDetectorTest.php │ └── Gitlab │ │ └── payloads │ │ ├── commits.json │ │ ├── mr.json │ │ └── files.json ├── Command │ ├── InitCommandTest.php │ ├── LocalCommandTest.php │ ├── CiCommandTest.php │ ├── GitlabCommandTest.php │ └── GithubCommandTest.php └── ConfigTest.php ├── .gitignore ├── src ├── Exception │ ├── InvalidConfigurationException.php │ ├── UnsupportedCIException.php │ └── CouldNotGetFileContentException.php ├── Struct │ ├── CommentCollection.php │ ├── Comment.php │ ├── Commit.php │ ├── Local │ │ ├── LocalFile.php │ │ └── LocalPullRequest.php │ ├── CommitCollection.php │ ├── File.php │ ├── Github │ │ ├── File.php │ │ └── PullRequest.php │ ├── FileCollection.php │ ├── Gitlab │ │ ├── File.php │ │ └── PullRequest.php │ ├── PullRequest.php │ └── Collection.php ├── DependencyInjection │ ├── Factory │ │ ├── GithubClientFactory.php │ │ └── GitlabClientFactory.php │ └── Container.php ├── Rule │ ├── ConditionRule.php │ ├── MaxCommitRule.php │ ├── CheckPhpStan.php │ ├── CommitRegexRule.php │ ├── Condition.php │ ├── DisallowRepeatedCommitsRule.php │ ├── MaxCommit.php │ ├── CommitRegex.php │ ├── CheckPhpStanRule.php │ ├── DisallowRepeatedCommits.php │ ├── CheckPhpCsFixerRule.php │ └── CheckPhpCsFixer.php ├── Runner.php ├── ConfigLoader.php ├── Platform │ ├── Local │ │ └── LocalPlatform.php │ ├── PlatformDetector.php │ ├── AbstractPlatform.php │ ├── Gitlab │ │ ├── Gitlab.php │ │ └── GitlabCommenter.php │ └── Github │ │ ├── GithubCommenter.php │ │ └── Github.php ├── Application.php ├── Command │ ├── InitCommand.php │ ├── AbstractPlatformCommand.php │ ├── CiCommand.php │ ├── GitlabCommand.php │ ├── GithubCommand.php │ └── LocalCommand.php ├── Resources │ └── services.php ├── Renderer │ └── HTMLRenderer.php ├── Context.php └── Config.php ├── phpstan.neon ├── action.yaml ├── .github ├── workflows │ ├── danger.yml │ ├── phpunit.yml │ ├── tools.yml │ └── release.yml └── dependabot.yml ├── infection.json.dist ├── box.json ├── bin └── danger ├── .chglog ├── CHANGELOG.tpl.md └── config.yml ├── Dockerfile ├── docs ├── commands.md ├── builtin-rules.md ├── ci.md ├── context.md └── getting_started.md ├── phpunit.xml.dist ├── .danger.php ├── LICENSE ├── composer.json ├── README.md └── .php-cs-fixer.dist.php /tests/fixtures/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/configs/empty.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | class CommentCollection extends Collection 10 | { 11 | } 12 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | inferPrivatePropertyTypeFromConstructor: true 4 | reportUnmatchedIgnoredErrors: false 5 | 6 | paths: 7 | - %currentWorkingDirectory%/src 8 | - %currentWorkingDirectory%/tests 9 | -------------------------------------------------------------------------------- /tests/configs/warning.php: -------------------------------------------------------------------------------- 1 | useRule(function (Danger\Context $context): void { 8 | $context->warning('Test'); 9 | }) 10 | ; 11 | -------------------------------------------------------------------------------- /tests/fixtures/SqlHeredocFixture.php: -------------------------------------------------------------------------------- 1 | useRule(function (Danger\Context $context): void { 8 | $context->failure('A Failure'); 9 | $context->warning('A Warning'); 10 | $context->notice('A Notice'); 11 | }) 12 | ; 13 | -------------------------------------------------------------------------------- /src/Struct/Commit.php: -------------------------------------------------------------------------------- 1 | content; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/CouldNotGetFileContentException.php: -------------------------------------------------------------------------------- 1 | file); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/danger.yml: -------------------------------------------------------------------------------- 1 | name: Danger 2 | on: 3 | pull_request_target: 4 | 5 | jobs: 6 | pr: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Clone 10 | uses: actions/checkout@v6 11 | 12 | - name: Run Danger 13 | uses: ./ 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | GITHUB_PULL_REQUEST_ID: ${{ github.event.pull_request.number }} 17 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "vendor/infection/infection/resources/schema.json", 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "mutators": { 9 | "@default": true 10 | }, 11 | "logs": { 12 | "text": "infection/infection.log", 13 | "summary": "infection/summary.log", 14 | "perMutator": "infection/per-mutator.md" 15 | } 16 | } -------------------------------------------------------------------------------- /src/Struct/CommitCollection.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | class CommitCollection extends Collection 10 | { 11 | /** 12 | * @return string[] 13 | */ 14 | public function getMessages(): array 15 | { 16 | return array_map(static fn (Commit $commit): string => $commit->message, $this->elements); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /box.json: -------------------------------------------------------------------------------- 1 | { 2 | "alias": "danger.phar", 3 | "directories": [ 4 | "src", 5 | "vendor" 6 | ], 7 | "compactors": [ 8 | "KevinGH\\Box\\Compactor\\Json", 9 | "KevinGH\\Box\\Compactor\\Php" 10 | ], 11 | "finder": [ 12 | { 13 | "name": "*.php", 14 | "exclude": [ 15 | "tests" 16 | ], 17 | "in": [ 18 | "vendor", 19 | "src" 20 | ] 21 | } 22 | ], 23 | "git-version": "package_version", 24 | "output": "danger.phar", 25 | "compression": "GZ" 26 | } -------------------------------------------------------------------------------- /bin/danger: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | run(); -------------------------------------------------------------------------------- /src/DependencyInjection/Factory/GithubClientFactory.php: -------------------------------------------------------------------------------- 1 | authenticate($_SERVER['GITHUB_TOKEN'], null, AuthMethod::ACCESS_TOKEN); 17 | } 18 | 19 | return $client; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.chglog/CHANGELOG.tpl.md: -------------------------------------------------------------------------------- 1 | {{ range .Versions }} 2 | 3 | {{ range .CommitGroups -}} 4 | ### {{ .Title }} 5 | 6 | {{ range .Commits -}} 7 | * {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} 8 | {{ end }} 9 | {{ end -}} 10 | 11 | {{- if .RevertCommits -}} 12 | ### Reverts 13 | 14 | {{ range .RevertCommits -}} 15 | * {{ .Revert.Header }} 16 | {{ end }} 17 | {{ end -}} 18 | 19 | {{- if .NoteGroups -}} 20 | {{ range .NoteGroups -}} 21 | ### {{ .Title }} 22 | 23 | {{ range .Notes }} 24 | {{ .Body }} 25 | {{ end }} 26 | {{ end -}} 27 | {{ end -}} 28 | {{ end -}} -------------------------------------------------------------------------------- /tests/Struct/Local/LocalFileTest.php: -------------------------------------------------------------------------------- 1 | getContent()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Struct/File.php: -------------------------------------------------------------------------------- 1 | setUrl($_SERVER['CI_SERVER_URL']); 16 | } 17 | 18 | if (isset($_SERVER['DANGER_GITLAB_TOKEN'])) { 19 | $client->authenticate($_SERVER['DANGER_GITLAB_TOKEN'], Client::AUTH_HTTP_TOKEN); 20 | } 21 | 22 | return $client; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Rule/ConditionRule.php: -------------------------------------------------------------------------------- 1 | getRules() as $rule) { 11 | $rule($context); 12 | } 13 | 14 | /** 15 | * Run after hooks. Can be used to set labels after all rules has been run 16 | */ 17 | foreach ($config->getAfterHooks() as $afterHook) { 18 | $afterHook($context); 19 | } 20 | 21 | if ($config->getReportLevel($context) < $config->getUseThreadOn()) { 22 | $config->useThreadOn(Config::REPORT_LEVEL_NONE); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ConfigLoader.php: -------------------------------------------------------------------------------- 1 | /dev/null', 12 | private string $message = 'PHPStan check failed. Run locally ./vendor/bin/phpstan --error-format=json --no-progress to see the errors.', 13 | ) { 14 | } 15 | 16 | public function __invoke(Context $context): void 17 | { 18 | exec($this->command, $cmdOutput, $resultCode); 19 | 20 | if ($resultCode !== 0) { 21 | $context->failure($this->message); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Rule/CommitRegexRule.php: -------------------------------------------------------------------------------- 1 | condition = $condition; 21 | } 22 | 23 | public function __invoke(Context $context): void 24 | { 25 | $cond = $this->condition; 26 | 27 | if (!$cond($context)) { 28 | return; 29 | } 30 | 31 | foreach ($this->rules as $rule) { 32 | $rule($context); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Rule/DisallowRepeatedCommitsRule.php: -------------------------------------------------------------------------------- 1 | platform->pullRequest->getCommits()) > $this->maxCommits) { 17 | $message = $this->maxCommits . ' commits'; 18 | if ($this->maxCommits === 1) { 19 | $message = 'one commit'; 20 | } 21 | 22 | $context->failure(str_replace(['###AMOUNT###'], [$message], $this->message)); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/shyim/wolfi-php/base:latest 2 | 3 | RUN < /bin/danger 21 | echo "php /danger.phar \"\$@\"" >> /bin/danger 22 | chmod +x /bin/danger 23 | 24 | mkdir -p /app/bin 25 | echo "#/bin/sh" > /app/bin/danger 26 | echo "php /danger.phar \"\$@\"" >> /app/bin/danger 27 | chmod +x /app/bin/danger 28 | EOF 29 | 30 | COPY danger.phar /danger.phar 31 | ENTRYPOINT [ "/usr/bin/php", "/danger.phar" ] 32 | -------------------------------------------------------------------------------- /docs/commands.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | ## Init 4 | 5 | This commands generates a default `.danger.php` file 6 | 7 | ## github-local 8 | 9 | This command runs the local Danger configuration against a Github PR without modifying anything. 10 | 11 | ### Parameters 12 | 13 | - Github Pull Request URL 14 | 15 | ### Environment variables 16 | 17 | - GITHUB_TOKEN with a GitHub token 18 | 19 | ## gitlab-local 20 | 21 | This command runs the local Danger configuration against a Gitlab PR without modifying anything; 22 | 23 | ### Parameters 24 | 25 | - Gitlab Project ID 26 | - Pull Request Number 27 | 28 | ### Environment variables 29 | 30 | - DANGER_GITLAB_TOKEN with a Gitlab Token with api scope 31 | 32 | ## ci 33 | 34 | This command runs Danger in CI mode and tries to detect the platform by environment variables -------------------------------------------------------------------------------- /src/Rule/CommitRegex.php: -------------------------------------------------------------------------------- 1 | platform->pullRequest->getCommits() as $commit) { 17 | $pregMatch = preg_match($this->regex, $commit->message); 18 | 19 | if ($pregMatch === 0 || $pregMatch === false) { 20 | $context->failure(str_replace(['###MESSAGE###', '###REGEX###'], [$commit->message, $this->regex], $this->message)); 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.chglog/config.yml: -------------------------------------------------------------------------------- 1 | style: github 2 | template: CHANGELOG.tpl.md 3 | info: 4 | title: CHANGELOG 5 | repository_url: https://github.com/FriendsOfShopware/FroshPluginUploader 6 | options: 7 | commits: 8 | filters: 9 | Type: 10 | - feat 11 | - fix 12 | - docs 13 | - perf 14 | - refactor 15 | - compat 16 | - chore 17 | commit_groups: 18 | title_maps: 19 | feat: Features 20 | docs: Documentation 21 | fix: Bug Fixes 22 | perf: Performance Improvements 23 | refactor: Code Refactoring 24 | compat: Compability 25 | chore: Chore 26 | header: 27 | pattern: '^(\w*)(?:\((\w*)\))?:\s(.*)$' 28 | pattern_maps: 29 | - Type 30 | - Scope 31 | - Subject 32 | notes: 33 | keywords: 34 | - BREAKING CHANGE -------------------------------------------------------------------------------- /src/DependencyInjection/Container.php: -------------------------------------------------------------------------------- 1 | registerForAutoconfiguration(Command::class)->addTag('console.command'); 17 | 18 | $loader = new PhpFileLoader($container, new FileLocator(\dirname(__DIR__) . '/Resources')); 19 | $loader->load('services.php'); 20 | $container->compile(); 21 | 22 | return $container; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | ./tests 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ./src/ 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Rule/CheckPhpStanRule.php: -------------------------------------------------------------------------------- 1 | ./vendor/bin/phpstan --error-format=json --no-progress to see the errors.', 20 | ) { 21 | $deprecationMessage = sprintf(Application::RULE_DEPRECATION_MESSAGE, CheckPhpStan::class); 22 | trigger_deprecation(Application::PACKAGE_NAME, '0.1.5', $deprecationMessage); 23 | 24 | parent::__construct($command, $message); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Struct/Github/File.php: -------------------------------------------------------------------------------- 1 | content !== null) { 22 | return $this->content; 23 | } 24 | 25 | $rawDownload = $this->client->repo()->contents()->rawDownload($this->owner, $this->repo, $this->fileName, $this->headSha); 26 | \assert(is_string($rawDownload)); 27 | $this->content = $rawDownload; 28 | 29 | return $this->content; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Renderer/HTMLRendererTest.php: -------------------------------------------------------------------------------- 1 | convert(new Context($this->createMock(Github::class)))); 20 | } 21 | 22 | public function testRenderFailure(): void 23 | { 24 | $renderer = new HTMLRenderer(); 25 | $context = new Context($this->createMock(Github::class)); 26 | $context->failure('Test'); 27 | 28 | static::assertStringContainsString(HTMLRenderer::MARKER, $renderer->convert($context)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Rule/DisallowRepeatedCommits.php: -------------------------------------------------------------------------------- 1 | platform->pullRequest->getCommits()->getMessages(); 18 | 19 | if ($context->platform instanceof Github) { 20 | $messages = array_filter( 21 | $messages, 22 | fn ($message) => !(preg_match('/^Merge branch .* into .*$/', $message) === 1), 23 | ); 24 | } 25 | 26 | if (\count($messages) !== \count(array_unique($messages))) { 27 | $context->failure($this->message); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/ApplicationTest.php: -------------------------------------------------------------------------------- 1 | getContainer()->has(CiCommand::class)); 27 | static::assertTrue($app->getContainer()->getDefinition(CiCommand::class)->hasTag('console.command')); 28 | 29 | unset($_SERVER['CI_SERVER_URL'], $_SERVER['DANGER_GITLAB_TOKEN'], $_SERVER['GITHUB_TOKEN']); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Platform/Local/LocalPlatform.php: -------------------------------------------------------------------------------- 1 | pullRequest = new LocalPullRequest($projectIdentifier, $localBranch, $targetBranch); 18 | } 19 | 20 | public function post(string $body, Config $config): string 21 | { 22 | $this->didCommented = true; 23 | 24 | return ''; 25 | } 26 | 27 | public function removePost(Config $config): void 28 | { 29 | } 30 | 31 | public function hasDangerMessage(): bool 32 | { 33 | return $this->didCommented; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Rule/CheckPhpCsFixerRule.php: -------------------------------------------------------------------------------- 1 | ./vendor/bin/php-cs-fixer fix on your branch', 21 | ) { 22 | $deprecationMessage = sprintf(Application::RULE_DEPRECATION_MESSAGE, CheckPhpCsFixer::class); 23 | trigger_deprecation(Application::PACKAGE_NAME, '0.1.5', $deprecationMessage); 24 | 25 | parent::__construct($command, $executionFailed, $foundErrors); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/ConfigLoaderTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidConfigurationException::class); 20 | 21 | $loader->loadByPath(\dirname(__DIR__) . '/danger.php'); 22 | } 23 | 24 | public function testLoadingWithoutFile(): void 25 | { 26 | $currentDir = getcwd(); 27 | static::assertIsString($currentDir); 28 | chdir('/tmp'); 29 | 30 | $loader = new ConfigLoader(); 31 | 32 | $this->expectException(InvalidConfigurationException::class); 33 | 34 | $loader->loadByPath(null); 35 | 36 | chdir($currentDir); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Struct/FileCollection.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | class FileCollection extends Collection 10 | { 11 | /** 12 | * @return FileCollection 13 | */ 14 | public function matches(string $pattern): self 15 | { 16 | return $this->filter(static fn (File $file): bool => fnmatch($pattern, $file->name)); 17 | } 18 | 19 | /** 20 | * @return FileCollection 21 | */ 22 | public function matchesContent(string $pattern): self 23 | { 24 | return $this->filter(static fn (File $file): bool => !(($matches = preg_grep($pattern, [$file->getContent()])) === false || \count($matches) === 0)); 25 | } 26 | 27 | /** 28 | * @return FileCollection 29 | */ 30 | public function filterStatus(string $status): self 31 | { 32 | return $this->filter(static fn (File $file): bool => $file->status === $status); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Struct/Gitlab/File.php: -------------------------------------------------------------------------------- 1 | content !== null) { 22 | return $this->content; 23 | } 24 | 25 | /** @var array{'content'?: string} $response */ 26 | $response = $this->client->repositoryFiles()->getFile($this->projectIdentifier, $this->path, $this->sha); 27 | $file = $response; 28 | 29 | if (isset($file['content'])) { 30 | return $this->content = (string) base64_decode($file['content'], true); 31 | } 32 | 33 | throw new \InvalidArgumentException(sprintf('Invalid file %s at sha %s', $this->path, $this->sha)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.danger.php: -------------------------------------------------------------------------------- 1 | useRule(new CommitRegex('/^(feat|ci|fix|docs|perf|refactor|compat|chore)(\(.+\))?\:\s(.{3,})/m')) 10 | ->useRule(static function (Context $context): void { 11 | $prFiles = $context 12 | ->platform 13 | ->pullRequest 14 | ->getFiles() 15 | ; 16 | 17 | $files = $prFiles 18 | ->matches('src/Rule/*') 19 | ->filterStatus(File::STATUS_ADDED) 20 | ; 21 | 22 | if ($files->count() && !$prFiles->has('docs/builtin-rules.md')) { 23 | $context->failure('You have added a new rule. Please change the docs too.'); 24 | } 25 | }) 26 | ->after(static function (Context $context): void { 27 | if ($context->hasFailures()) { 28 | $context->platform->addLabels('Incomplete'); 29 | 30 | return; 31 | } 32 | 33 | $context->platform->removeLabels('Incomplete'); 34 | }) 35 | ; 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Shyim 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 all 13 | 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 THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/Rule/CheckPhpStanTest.php: -------------------------------------------------------------------------------- 1 | createMock(Github::class); 19 | $context = new Context($github); 20 | 21 | $rule = new CheckPhpStan(); 22 | $rule($context); 23 | 24 | static::assertFalse($context->hasFailures()); 25 | } 26 | 27 | public function testInvalid(): void 28 | { 29 | $github = $this->createMock(Github::class); 30 | $context = new Context($github); 31 | 32 | $path = \dirname(__DIR__, 2) . '/src/Test.php'; 33 | 34 | file_put_contents($path, 'hasFailures()); 40 | 41 | \unlink($path); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Application.php: -------------------------------------------------------------------------------- 1 | container = Container::getContainer(); 22 | 23 | foreach (array_keys($this->container->findTaggedServiceIds('console.command')) as $taggedService) { 24 | /** @var Command $command */ 25 | $command = $this->container->get($taggedService); 26 | 27 | $this->add($command); 28 | } 29 | } 30 | 31 | public function getContainer(): ContainerBuilder 32 | { 33 | return $this->container; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/RunnerTest.php: -------------------------------------------------------------------------------- 1 | useRule(function () use (&$ruleExecuted, &$afterExecuted): void { 26 | static::assertFalse($afterExecuted); /** @phpstan-ignore-line */ 27 | $ruleExecuted = true; 28 | }); 29 | 30 | $config->after(function () use (&$ruleExecuted, &$afterExecuted): void { 31 | static::assertTrue($ruleExecuted); 32 | $afterExecuted = true; 33 | }); 34 | 35 | $config->useThreadOn(Config::REPORT_LEVEL_FAILURE); 36 | $runner->run($config, new Context($this->createMock(Github::class))); 37 | 38 | static::assertTrue($ruleExecuted); 39 | static::assertTrue($afterExecuted); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Command/InitCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Initializes a new danger.php'); 18 | } 19 | 20 | protected function execute(InputInterface $input, OutputInterface $output): int 21 | { 22 | $path = getcwd() . '/.danger.php'; 23 | 24 | $io = new SymfonyStyle($input, $output); 25 | 26 | if (is_file($path) && !$io->confirm('A .danger.php file does already exist. Do you want to override it?')) { 27 | return 0; 28 | } 29 | 30 | file_put_contents($path, 'useRule(new DisallowRepeatedCommits) // Disallows multiple commits with the same message 37 | ; 38 | ', \LOCK_EX); 39 | $io->success(sprintf('Created %s', $path)); 40 | 41 | return 0; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Resources/services.php: -------------------------------------------------------------------------------- 1 | services() 16 | ->defaults() 17 | ->public() 18 | ->autowire() 19 | ->autoconfigure() 20 | ->load('Danger\\', dirname(__DIR__)) 21 | ->exclude(dirname(__DIR__) . '/{Struct,Exception,Rule,Resources,Context.php}') 22 | ; 23 | 24 | $configurator 25 | ->services() 26 | ->set(GithubClient::class) 27 | ->factory([GithubClientFactory::class, 'build']) 28 | ; 29 | 30 | $configurator 31 | ->services() 32 | ->set(GitlabClient::class) 33 | ->factory([GitlabClientFactory::class, 'build']) 34 | ; 35 | 36 | $configurator 37 | ->services() 38 | ->set(HttpClientInterface::class) 39 | ->factory([HttpClient::class, 'create']) 40 | ; 41 | }; 42 | -------------------------------------------------------------------------------- /src/Platform/PlatformDetector.php: -------------------------------------------------------------------------------- 1 | createFromGithubContext(); 20 | } 21 | 22 | if (isset($_SERVER['GITLAB_CI'], $_SERVER['CI_PROJECT_ID'], $_SERVER['CI_MERGE_REQUEST_IID'], $_SERVER['DANGER_GITLAB_TOKEN'])) { 23 | return $this->createFromGitlabContext(); 24 | } 25 | 26 | throw new UnsupportedCIException(); 27 | } 28 | 29 | private function createFromGithubContext(): AbstractPlatform 30 | { 31 | $this->github->load($_SERVER['GITHUB_REPOSITORY'], $_SERVER['GITHUB_PULL_REQUEST_ID']); 32 | 33 | return $this->github; 34 | } 35 | 36 | private function createFromGitlabContext(): AbstractPlatform 37 | { 38 | $this->gitlab->load($_SERVER['CI_PROJECT_ID'], $_SERVER['CI_MERGE_REQUEST_IID']); 39 | 40 | return $this->gitlab; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/phpunit.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | paths: 7 | - "**.php" 8 | - "composer.json" 9 | pull_request: 10 | paths: 11 | - "**.php" 12 | - "composer.json" 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | php: ['8.2', '8.3'] 20 | steps: 21 | - uses: actions/checkout@v6 22 | 23 | - uses: shivammathur/setup-php@v2 24 | with: 25 | php-version: ${{ matrix.php }} 26 | extensions: xdebug 27 | 28 | - name: Get composer cache directory 29 | id: composer-cache 30 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 31 | 32 | - name: Cache dependencies 33 | uses: actions/cache@v5 34 | with: 35 | path: ${{ steps.composer-cache.outputs.dir }} 36 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 37 | restore-keys: ${{ runner.os }}-composer- 38 | 39 | - name: Install dependencies 40 | run: composer install --prefer-dist --no-progress --no-suggest 41 | 42 | - name: Run test suite 43 | run: php vendor/bin/phpunit 44 | env: 45 | PHP_CS_FIXER_IGNORE_ENV: 1 46 | 47 | - uses: codecov/codecov-action@v5 48 | with: 49 | file: ./clover.xml 50 | -------------------------------------------------------------------------------- /src/Command/AbstractPlatformCommand.php: -------------------------------------------------------------------------------- 1 | hasReports()) { 18 | $io->success('PR looks good!'); 19 | 20 | return 0; 21 | } 22 | 23 | $failed = false; 24 | 25 | if ($context->hasFailures()) { 26 | $io->table(['Failures'], array_map(static fn (string $msg) => [$msg], $context->getFailures())); 27 | $failed = true; 28 | } 29 | 30 | if ($context->hasWarnings()) { 31 | $io->table(['Warnings'], array_map(static fn (string $msg) => [$msg], $context->getWarnings())); 32 | } 33 | 34 | if ($context->hasNotices()) { 35 | $io->table(['Notices'], array_map(static fn (string $msg) => [$msg], $context->getNotices())); 36 | } 37 | 38 | return $failed ? self::FAILURE : self::SUCCESS; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Rule/MaxCommitTest.php: -------------------------------------------------------------------------------- 1 | createMock(Github::class); 22 | $pr = $this->createMock(PullRequest::class); 23 | $pr->method('getCommits')->willReturn(new CommitCollection([new Commit(), new Commit()])); 24 | $github->pullRequest = $pr; 25 | 26 | $context = new Context($github); 27 | 28 | $rule = new MaxCommit(); 29 | $rule($context); 30 | 31 | static::assertTrue($context->hasFailures()); 32 | } 33 | 34 | public function testRuleNotMatches(): void 35 | { 36 | $github = $this->createMock(Github::class); 37 | $pr = $this->createMock(PullRequest::class); 38 | $pr->method('getCommits')->willReturn(new CommitCollection([new Commit()])); 39 | $github->pullRequest = $pr; 40 | 41 | $context = new Context($github); 42 | 43 | $rule = new MaxCommit(); 44 | $rule($context); 45 | 46 | static::assertFalse($context->hasFailures()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Rule/ConditionTest.php: -------------------------------------------------------------------------------- 1 | createMock(Github::class); 20 | $context = new Context($github); 21 | $innerRuleExecuted = false; 22 | 23 | $rule = new Condition( 24 | static fn (Context $context): bool => $context->platform instanceof Github, 25 | [ 26 | static function () use (&$innerRuleExecuted): void { 27 | $innerRuleExecuted = true; 28 | }, 29 | ] 30 | ); 31 | 32 | $rule($context); 33 | 34 | static::assertTrue($innerRuleExecuted); 35 | } 36 | 37 | public function testConditionNotMet(): void 38 | { 39 | $github = $this->createMock(Github::class); 40 | $context = new Context($github); 41 | $innerRuleExecuted = false; 42 | 43 | $rule = new Condition( 44 | static fn (Context $context): bool => $context->platform instanceof Gitlab, 45 | [ 46 | static function () use (&$innerRuleExecuted): void { 47 | $innerRuleExecuted = true; 48 | }, 49 | ] 50 | ); 51 | 52 | $rule($context); 53 | 54 | static::assertFalse($innerRuleExecuted); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Struct/PullRequest.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public array $rawCommits = []; 39 | 40 | /** 41 | * @return CommitCollection 42 | */ 43 | abstract public function getCommits(): CommitCollection; 44 | 45 | /** 46 | * @return FileCollection 47 | */ 48 | abstract public function getFiles(): FileCollection; 49 | 50 | /** 51 | * Returns a single file from the pull request head. 52 | */ 53 | abstract public function getFile(string $fileName): File; 54 | 55 | /** 56 | * Get a file from the pull request head. Don't need to be a changed file. 57 | */ 58 | abstract public function getFileContent(string $path): string; 59 | 60 | /** 61 | * @return CommentCollection 62 | */ 63 | abstract public function getComments(): CommentCollection; 64 | } 65 | -------------------------------------------------------------------------------- /src/Platform/AbstractPlatform.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public array $raw = []; 17 | 18 | /** 19 | * @internal Only for internal Danger usage 20 | */ 21 | abstract public function load(string $projectIdentifier, string $id): void; 22 | 23 | /** 24 | * @internal Only for internal Danger usage 25 | */ 26 | abstract public function post(string $body, Config $config): string; 27 | 28 | /** 29 | * @internal Only for internal Danger usage 30 | */ 31 | abstract public function removePost(Config $config): void; 32 | 33 | public function addLabels(string ...$labels): void 34 | { 35 | foreach ($labels as $label) { 36 | $this->pullRequest->labels[] = $label; 37 | } 38 | 39 | $this->pullRequest->labels = array_unique($this->pullRequest->labels); 40 | } 41 | 42 | public function removeLabels(string ...$labels): void 43 | { 44 | $prLabels = array_flip($this->pullRequest->labels); 45 | 46 | foreach ($labels as $label) { 47 | if (isset($prLabels[$label])) { 48 | unset($prLabels[$label]); 49 | } 50 | } 51 | 52 | $this->pullRequest->labels = array_flip($prLabels); 53 | } 54 | 55 | /** 56 | * Can be used to determine has the pull request a danger comment 57 | */ 58 | abstract public function hasDangerMessage(): bool; 59 | } 60 | -------------------------------------------------------------------------------- /docs/builtin-rules.md: -------------------------------------------------------------------------------- 1 | # Builtin rules 2 | 3 | ## \Danger\Rule\CheckPhpCsFixer 4 | 5 | Runs PHP-CS-Fixer in background and adds a failure if the command is failed 6 | 7 | ## \Danger\Rule\CommitRegex 8 | 9 | Checks that the Commit message matches the regex 10 | 11 | ### Parameters 12 | 13 | - regex (string) 14 | - (optional) message (string) 15 | 16 | ## \Danger\Rule\DisallowRepeatedCommits 17 | 18 | Checks that the commit messages are unique inside the pull request 19 | 20 | ### Parameters 21 | 22 | - (optional) message (string) 23 | 24 | ## \Danger\Rule\MaxCommit 25 | 26 | Checks the commit amount in the pull request 27 | 28 | ### Parameters 29 | 30 | - maxAmount (int) default: 1 31 | - (optional) message (string) 32 | 33 | ## \Danger\Rule\Condition 34 | 35 | Allows running multiple rules when a condition is met 36 | 37 | ### Parameters 38 | 39 | - function which checks are the condition met 40 | - array of rules to be executed 41 | 42 | 43 | ### Example 44 | 45 | ```php 46 | useRule(new Condition( 60 | function (Context $context) { 61 | return $context->platform instanceof Github; 62 | }, 63 | [ 64 | new MaxCommit(1), 65 | new CommitRegex('/^(feat|fix|docs|perf|refactor|compat|chore)(\(.+\))?\:\s(.{3,})/m') 66 | ] 67 | )); 68 | ``` -------------------------------------------------------------------------------- /src/Renderer/HTMLRenderer.php: -------------------------------------------------------------------------------- 1 | '; 11 | 12 | private const TABLE_TPL = <<<'TABLE' 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ##CONTENT## 22 | 23 |
##NAME##
24 | TABLE; 25 | 26 | private const ITEM_TPL = <<<'ITEM' 27 | 28 | ##EMOJI## 29 | ##MSG## 30 | 31 | ITEM; 32 | 33 | public function convert(Context $context): string 34 | { 35 | $content = self::MARKER; 36 | 37 | return 38 | $content . 39 | $this->render('Fails', ':no_entry_sign:', $context->getFailures()) . 40 | $this->render('Warnings', ':warning:', $context->getWarnings()) . 41 | $this->render('Notice', ':book:', $context->getNotices()); 42 | } 43 | 44 | /** 45 | * @param string[] $entries 46 | */ 47 | private function render(string $name, string $emoji, array $entries): string 48 | { 49 | if ($entries === []) { 50 | return ''; 51 | } 52 | 53 | $items = ''; 54 | 55 | foreach ($entries as $entry) { 56 | $items .= \str_replace(['##EMOJI##', '##MSG##'], [$emoji, $entry], self::ITEM_TPL); 57 | } 58 | 59 | return \str_replace(['##NAME##', '##CONTENT##'], [$name, $items], self::TABLE_TPL); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Rule/CommitRegexTest.php: -------------------------------------------------------------------------------- 1 | message = 'Test'; 23 | 24 | $github = $this->createMock(Github::class); 25 | $pr = $this->createMock(PullRequest::class); 26 | $pr->method('getCommits')->willReturn(new CommitCollection([$commit])); 27 | $github->pullRequest = $pr; 28 | 29 | $context = new Context($github); 30 | 31 | $rule = new CommitRegex('/^(feat|fix|docs|perf|refactor|compat|chore)(\(.+\))?\:\s(.{3,})/m'); 32 | $rule($context); 33 | 34 | static::assertTrue($context->hasFailures()); 35 | } 36 | 37 | public function testRuleNotMatches(): void 38 | { 39 | $commit = new Commit(); 40 | $commit->message = 'feat: Test'; 41 | 42 | $github = $this->createMock(Github::class); 43 | $pr = $this->createMock(PullRequest::class); 44 | $pr->method('getCommits')->willReturn(new CommitCollection([$commit])); 45 | $github->pullRequest = $pr; 46 | 47 | $context = new Context($github); 48 | $rule = new CommitRegex('/^(feat|fix|docs|perf|refactor|compat|chore)(\(.+\))?\:\s(.{3,})/m'); 49 | 50 | $rule($context); 51 | 52 | static::assertFalse($context->hasFailures()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Rule/CheckPhpCsFixerTest.php: -------------------------------------------------------------------------------- 1 | createMock(Github::class); 22 | $pullRequest = $this->createMock(PullRequest::class); 23 | $github->pullRequest = $pullRequest; 24 | $pullRequest->method('getFiles')->willReturn(new FileCollection([ 25 | new TestFile('test.php', 'hasFailures()); 38 | } 39 | 40 | public function testRuleFailures(): void 41 | { 42 | $github = $this->createMock(Github::class); 43 | $pullRequest = $this->createMock(PullRequest::class); 44 | $github->pullRequest = $pullRequest; 45 | $pullRequest->method('getFiles')->willReturn(new FileCollection([ 46 | new TestFile('test.php', 'hasFailures()); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docs/ci.md: -------------------------------------------------------------------------------- 1 | # CI Integration 2 | 3 | ## Github Actions 4 | 5 | ### pull_request_target 6 | 7 | This uses the target branch instead the pull request branch. All changes will be not locally available. 8 | With this method all operations like commenting and labeling will work as github-actions user 9 | 10 | ```yaml 11 | name: Danger 12 | on: 13 | pull_request_target: 14 | 15 | jobs: 16 | pr: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Clone 20 | uses: actions/checkout@v2.4.0 21 | 22 | - name: Danger 23 | uses: shyim/danger-php@0.2.8 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | GITHUB_PULL_REQUEST_ID: ${{ github.event.pull_request.number }} 27 | ``` 28 | 29 | ### pull_request 30 | 31 | **Warning**: This will only function on pull requests in the same repository. Checkout [this](./getting_started.md) for an entire setup. 32 | Label functions will not work on pull requests coming from forks. 33 | 34 | ```yaml 35 | name: Danger 36 | on: 37 | pull_request: 38 | 39 | jobs: 40 | pr: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Clone 44 | uses: actions/checkout@v1 45 | 46 | - name: Danger 47 | uses: shyim/danger-php@0.2.8 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | GITHUB_PULL_REQUEST_ID: ${{ github.event.pull_request.number }} 51 | ``` 52 | 53 | ## GitLab CI 54 | 55 | ```yaml 56 | Danger: 57 | image: 58 | name: ghcr.io/shyim/danger-php:latest 59 | entrypoint: [""] 60 | rules: 61 | - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' 62 | script: 63 | - danger ci 64 | ``` 65 | 66 | You will need also a new environment variable `DANGER_GITLAB_TOKEN` with a Gitlab Token to be able to post the message. 67 | For this purpose you should use a Bot account 68 | -------------------------------------------------------------------------------- /src/Rule/CheckPhpCsFixer.php: -------------------------------------------------------------------------------- 1 | ./vendor/bin/php-cs-fixer fix on your branch', 19 | ) { 20 | } 21 | 22 | public function __invoke(Context $context): void 23 | { 24 | $fs = new Filesystem(); 25 | $tempFolder = sys_get_temp_dir() . '/' . uniqid('danger', true); 26 | 27 | $fs->mkdir($tempFolder); 28 | 29 | $files = $context 30 | ->platform 31 | ->pullRequest 32 | ->getFiles() 33 | ->matches('*.php') 34 | ; 35 | 36 | /** @var File $file */ 37 | foreach ($files as $file) { 38 | $fs->dumpFile($tempFolder . '/' . $file->name, $file->getContent()); 39 | } 40 | 41 | exec($this->command . ' ' . $tempFolder . ' 2> /dev/null', $cmdOutput); 42 | 43 | $fs->remove($tempFolder); 44 | 45 | // @codeCoverageIgnoreStart 46 | if (!isset($cmdOutput[0])) { 47 | $context->failure($this->executionFailed); 48 | } 49 | // @codeCoverageIgnoreEnd 50 | 51 | /** @var array{'files': string[]} $json */ 52 | $json = json_decode($cmdOutput[0], true, 512, \JSON_THROW_ON_ERROR); 53 | 54 | if (\count($json['files']) > 0) { 55 | $context->failure($this->foundErrors); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Context.php: -------------------------------------------------------------------------------- 1 | failures[] = $text; 32 | } 33 | 34 | public function warning(string $text): void 35 | { 36 | $this->warnings[] = $text; 37 | } 38 | 39 | public function notice(string $text): void 40 | { 41 | $this->notices[] = $text; 42 | } 43 | 44 | public function hasReports(): bool 45 | { 46 | return $this->hasFailures() || $this->hasNotices() || $this->hasWarnings(); 47 | } 48 | 49 | /** 50 | * @return string[] 51 | */ 52 | public function getFailures(): array 53 | { 54 | return $this->failures; 55 | } 56 | 57 | public function hasFailures(): bool 58 | { 59 | return \count($this->failures) > 0; 60 | } 61 | 62 | /** 63 | * @return string[] 64 | */ 65 | public function getWarnings(): array 66 | { 67 | return $this->warnings; 68 | } 69 | 70 | public function hasWarnings(): bool 71 | { 72 | return \count($this->warnings) > 0; 73 | } 74 | 75 | /** 76 | * @return string[] 77 | */ 78 | public function getNotices(): array 79 | { 80 | return $this->notices; 81 | } 82 | 83 | public function hasNotices(): bool 84 | { 85 | return \count($this->notices) > 0; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/Platform/Github/payloads/reviews.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 637002629, 4 | "node_id": "MDE3OlB1bGxSZXF1ZXN0UmV2aWV3NjM3MDAyNjI5", 5 | "user": { 6 | "login": "dangertestuser2", 7 | "id": 6224096, 8 | "node_id": "MDQ6VXNlcjYyMjQwOTY=", 9 | "avatar_url": "https://avatars.githubusercontent.com/u/6224096?u=18be3a2d46f07dd42fc2b6dee9b4b9b68bca28d2&v=4", 10 | "gravatar_id": "", 11 | "url": "https://api.github.com/users/shyim", 12 | "html_url": "https://github.com/shyim", 13 | "followers_url": "https://api.github.com/users/shyim/followers", 14 | "following_url": "https://api.github.com/users/shyim/following{/other_user}", 15 | "gists_url": "https://api.github.com/users/shyim/gists{/gist_id}", 16 | "starred_url": "https://api.github.com/users/shyim/starred{/owner}{/repo}", 17 | "subscriptions_url": "https://api.github.com/users/shyim/subscriptions", 18 | "organizations_url": "https://api.github.com/users/shyim/orgs", 19 | "repos_url": "https://api.github.com/users/shyim/repos", 20 | "events_url": "https://api.github.com/users/shyim/events{/privacy}", 21 | "received_events_url": "https://api.github.com/users/shyim/received_events", 22 | "type": "User", 23 | "site_admin": false 24 | }, 25 | "body": "", 26 | "state": "COMMENTED", 27 | "html_url": "https://github.com/shopware/platform/pull/1775#pullrequestreview-637002629", 28 | "pull_request_url": "https://api.github.com/repos/shopware/platform/pulls/1775", 29 | "author_association": "MEMBER", 30 | "_links": { 31 | "html": { 32 | "href": "https://github.com/shopware/platform/pull/1775#pullrequestreview-637002629" 33 | }, 34 | "pull_request": { 35 | "href": "https://api.github.com/repos/shopware/platform/pulls/1775" 36 | } 37 | }, 38 | "submitted_at": "2021-04-15T18:21:57Z", 39 | "commit_id": "78297ad0ff17a11d16fb67c9d0b13812ce81ff0d" 40 | } 41 | ] -------------------------------------------------------------------------------- /tests/Platform/Local/LocalPlatformTest.php: -------------------------------------------------------------------------------- 1 | hasDangerMessage()); 23 | $platform->removePost(new Config()); 24 | static::assertFalse($platform->hasDangerMessage()); 25 | 26 | $platform->post('test', new Config()); 27 | static::assertTrue($platform->hasDangerMessage()); 28 | } 29 | 30 | public function testLoad(): void 31 | { 32 | $tmpDir = sys_get_temp_dir() . '/' . uniqid('local', true); 33 | 34 | mkdir($tmpDir); 35 | file_put_contents($tmpDir . '/a.txt', 'a'); 36 | 37 | (new Process(['git', 'init'], $tmpDir))->mustRun(); 38 | (new Process(['git', 'config', 'commit.gpgsign', 'false'], $tmpDir))->mustRun(); 39 | (new Process(['git', 'config', 'user.name', 'PHPUnit'], $tmpDir))->mustRun(); 40 | (new Process(['git', 'config', 'user.email', 'unit@php.com'], $tmpDir))->mustRun(); 41 | (new Process(['git', 'branch', '-m', 'main'], $tmpDir))->mustRun(); 42 | (new Process(['git', 'add', 'a.txt'], $tmpDir))->mustRun(); 43 | (new Process(['git', 'commit', '-m', 'initial'], $tmpDir))->mustRun(); 44 | 45 | $platform = new LocalPlatform(); 46 | $platform->load($tmpDir, 'main|main'); 47 | 48 | static::assertInstanceOf(LocalPullRequest::class, $platform->pullRequest); 49 | static::assertSame('empty', $platform->pullRequest->title); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Platform/PlatformDetectorTest.php: -------------------------------------------------------------------------------- 1 | createMock(Github::class), $this->createMock(Gitlab::class)); 20 | static::expectException(UnsupportedCIException::class); 21 | static::expectExceptionMessage('Could not detect CI Platform'); 22 | $detector->detect(); 23 | } 24 | 25 | public function testGithub(): void 26 | { 27 | $_SERVER['GITHUB_REPOSITORY'] = 'foo'; 28 | $_SERVER['GITHUB_PULL_REQUEST_ID'] = 'foo'; 29 | $_SERVER['GITHUB_TOKEN'] = 'foo'; 30 | 31 | $github = $this->createMock(Github::class); 32 | $github->expects(static::once())->method('load'); 33 | 34 | $detector = new PlatformDetector($github, $this->createMock(Gitlab::class)); 35 | static::assertSame($github, $detector->detect()); 36 | 37 | unset($_SERVER['GITHUB_REPOSITORY'], $_SERVER['GITHUB_PULL_REQUEST_ID'], $_SERVER['GITHUB_TOKEN']); 38 | } 39 | 40 | public function testGitlab(): void 41 | { 42 | $_SERVER['GITLAB_CI'] = 'foo'; 43 | $_SERVER['CI_PROJECT_ID'] = 'foo'; 44 | $_SERVER['CI_MERGE_REQUEST_IID'] = 'foo'; 45 | $_SERVER['DANGER_GITLAB_TOKEN'] = 'foo'; 46 | 47 | $gitlab = $this->createMock(Gitlab::class); 48 | $gitlab->expects(static::once())->method('load'); 49 | 50 | $detector = new PlatformDetector($this->createMock(Github::class), $gitlab); 51 | static::assertSame($gitlab, $detector->detect()); 52 | 53 | unset($_SERVER['GITLAB_CI'], $_SERVER['CI_PROJECT_ID'], $_SERVER['CI_MERGE_REQUEST_IID'], $_SERVER['DANGER_GITLAB_TOKEN']); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/tools.yml: -------------------------------------------------------------------------------- 1 | name: Tools 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | paths: 7 | - "**.php" 8 | - "composer.json" 9 | pull_request: 10 | paths: 11 | - "**.php" 12 | - "composer.json" 13 | 14 | jobs: 15 | phpstan: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v6 19 | 20 | - uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: 8.2 23 | 24 | - name: Get composer cache directory 25 | id: composer-cache 26 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 27 | 28 | - name: Cache dependencies 29 | uses: actions/cache@v5 30 | with: 31 | path: ${{ steps.composer-cache.outputs.dir }} 32 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 33 | restore-keys: ${{ runner.os }}-composer- 34 | 35 | - name: Install dependencies 36 | run: composer install --prefer-dist --no-progress --no-suggest 37 | 38 | - name: Run PhpStan 39 | run: vendor/bin/phpstan analyse 40 | cs-fixer: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v6 44 | 45 | - uses: shivammathur/setup-php@v2 46 | with: 47 | php-version: 8.2 48 | 49 | - name: Get composer cache directory 50 | id: composer-cache 51 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 52 | 53 | - name: Cache dependencies 54 | uses: actions/cache@v5 55 | with: 56 | path: ${{ steps.composer-cache.outputs.dir }} 57 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 58 | restore-keys: ${{ runner.os }}-composer- 59 | 60 | - name: Install dependencies 61 | run: composer install --prefer-dist --no-progress --no-suggest 62 | 63 | - name: Run PHP-CS-Fixer 64 | run: vendor/bin/php-cs-fixer fix 65 | 66 | - name: suggester / shellcheck 67 | uses: reviewdog/action-suggester@v1 68 | with: 69 | tool_name: php-cs-fixer 70 | -------------------------------------------------------------------------------- /src/Command/CiCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Runs danger on CI') 34 | ->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Path to Config file') 35 | ; 36 | } 37 | 38 | public function execute(InputInterface $input, OutputInterface $output): int 39 | { 40 | $context = new Context($this->platformDetector->detect()); 41 | 42 | $config = $input->getOption('config'); 43 | 44 | if ($config !== null && !\is_string($config)) { 45 | throw new \InvalidArgumentException('Invalid config option given'); 46 | } 47 | 48 | $config = $this->configLoader->loadByPath($config); 49 | 50 | $this->runner->run($config, $context); 51 | $io = new SymfonyStyle($input, $output); 52 | 53 | if (!$context->hasReports()) { 54 | $context->platform->removePost($config); 55 | 56 | $io->success('Looks good!'); 57 | 58 | return self::SUCCESS; 59 | } 60 | 61 | $body = $this->renderer->convert($context); 62 | 63 | $commentLink = $context->platform->post($body, $config); 64 | 65 | $io->info('The comment has been created at ' . $commentLink); 66 | 67 | return $context->hasFailures() ? self::FAILURE : self::SUCCESS; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/Command/InitCommandTest.php: -------------------------------------------------------------------------------- 1 | run(new ArgvInput([]), $output)); 30 | 31 | static::assertFileExists('/tmp/.danger.php'); 32 | static::assertStringContainsString('Created', $output->fetch()); 33 | unlink('/tmp/.danger.php'); 34 | chdir($currentDir); 35 | } 36 | 37 | public function testOverwriteFile(): void 38 | { 39 | $currentDir = getcwd(); 40 | static::assertIsString($currentDir); 41 | chdir('/tmp'); 42 | touch('/tmp/.danger.php'); 43 | 44 | $cmd = new InitCommand(); 45 | $tester = new CommandTester($cmd); 46 | $tester->setInputs(['yes']); 47 | static::assertSame(0, $tester->execute([], ['interactive' => true])); 48 | 49 | static::assertFileExists('/tmp/.danger.php'); 50 | static::assertStringContainsString('Created', $tester->getDisplay()); 51 | unlink('/tmp/.danger.php'); 52 | chdir($currentDir); 53 | } 54 | 55 | public function testNotOverwriteFile(): void 56 | { 57 | $currentDir = getcwd(); 58 | static::assertIsString($currentDir); 59 | chdir('/tmp'); 60 | touch('/tmp/.danger.php'); 61 | 62 | $cmd = new InitCommand(); 63 | $tester = new CommandTester($cmd); 64 | $tester->setInputs(['no']); 65 | static::assertSame(0, $tester->execute([], ['interactive' => true])); 66 | 67 | static::assertFileExists('/tmp/.danger.php'); 68 | static::assertStringNotContainsString('Created', $tester->getDisplay()); 69 | unlink('/tmp/.danger.php'); 70 | chdir($currentDir); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Command/GitlabCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Run local danger against an Gitlab PR without Commenting') 29 | ->addArgument('projectIdentifier', InputArgument::REQUIRED, 'Gitlab Project ID') 30 | ->addArgument('mrID', InputArgument::REQUIRED, 'Gitlab Merge Request ID') 31 | ->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Path to Config file') 32 | ; 33 | } 34 | 35 | public function execute(InputInterface $input, OutputInterface $output): int 36 | { 37 | $io = new SymfonyStyle($input, $output); 38 | 39 | if (!isset($_SERVER['DANGER_GITLAB_TOKEN'])) { 40 | $io->error('You need the environment variable DANGER_GITLAB_TOKEN with an Gitlab API Token to use this command'); 41 | 42 | return self::FAILURE; 43 | } 44 | 45 | $projectIdentifier = $input->getArgument('projectIdentifier'); 46 | $mrID = $input->getArgument('mrID'); 47 | 48 | \assert(\is_string($projectIdentifier)); 49 | \assert(\is_string($mrID)); 50 | 51 | $configPath = $input->getOption('config'); 52 | 53 | if ($configPath !== null && !\is_string($configPath)) { 54 | throw new \InvalidArgumentException('Invalid config option given'); 55 | } 56 | 57 | $this->gitlab->load($projectIdentifier, $mrID); 58 | 59 | $context = new Context($this->gitlab); 60 | $config = $this->configLoader->loadByPath($configPath); 61 | 62 | $this->runner->run($config, $context); 63 | 64 | return $this->handleReport($input, $output, $context); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Command/GithubCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Run local danger against an Github PR without Commenting') 28 | ->addArgument('pr', InputArgument::REQUIRED, 'Github PR URL') 29 | ->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Path to Config file') 30 | ; 31 | } 32 | 33 | public function execute(InputInterface $input, OutputInterface $output): int 34 | { 35 | $configPath = $input->getOption('config'); 36 | 37 | if ($configPath !== null && !\is_string($configPath)) { 38 | throw new \InvalidArgumentException('Invalid config option given'); 39 | } 40 | 41 | $prLink = $input->getArgument('pr'); 42 | 43 | if (!\is_string($prLink)) { 44 | throw new \InvalidArgumentException('The PR links needs to be a string'); 45 | } 46 | 47 | $context = $this->assembleContextByUrl($prLink); 48 | $config = $this->configLoader->loadByPath($configPath); 49 | 50 | $this->runner->run($config, $context); 51 | 52 | return $this->handleReport($input, $output, $context); 53 | } 54 | 55 | private function assembleContextByUrl(string $url): Context 56 | { 57 | $pregMatch = preg_match('/^https:\/\/github\.com\/(?[\w\-]*)\/(?[\w\-]*)\/pull\/(?\d*)/', $url, $matches); 58 | 59 | if ($pregMatch === 0 || !isset($matches['owner'], $matches['repo'], $matches['id'])) { 60 | throw new \InvalidArgumentException('The given url must be a valid Github URL'); 61 | } 62 | 63 | $this->github->load($matches['owner'] . '/' . $matches['repo'], $matches['id']); 64 | 65 | return new Context($this->github); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shyim/danger-php", 3 | "description": "Port of danger to PHP", 4 | "type": "project", 5 | "bin": [ 6 | "bin/danger" 7 | ], 8 | "autoload": { 9 | "psr-4": { 10 | "Danger\\": "src/" 11 | } 12 | }, 13 | "autoload-dev": { 14 | "psr-4": { 15 | "Danger\\Tests\\": "tests/" 16 | } 17 | }, 18 | "require": { 19 | "php": "^8.2", 20 | "ext-mbstring": "*", 21 | "ext-ctype": "*", 22 | "ext-intl": "*", 23 | "symfony/console": "^7.2", 24 | "symfony/dependency-injection": "^7.2", 25 | "symfony/filesystem": "^7.2", 26 | "symfony/process": "^7.2", 27 | "knplabs/github-api": "^3.16", 28 | "symfony/config": "^7.2", 29 | "symfony/http-client": "^7.2", 30 | "nyholm/psr7": "^1.8", 31 | "symfony/finder": "^7.2", 32 | "m4tthumphrey/php-gitlab-api": "^12.0" 33 | }, 34 | "replace": { 35 | "symfony/polyfill-ctype": "*", 36 | "symfony/polyfill-intl-grapheme": "*", 37 | "symfony/polyfill-intl-normalizer": "*", 38 | "symfony/polyfill-mbstring": "*", 39 | "symfony/polyfill-php72": "*", 40 | "symfony/polyfill-php73": "*", 41 | "symfony/polyfill-php80": "*", 42 | "symfony/polyfill-php81": "*", 43 | "symfony/polyfill-php82": "*" 44 | }, 45 | "license": "mit", 46 | "authors": [ 47 | { 48 | "name": "Soner Sayakci", 49 | "email": "github@shyim.de" 50 | } 51 | ], 52 | "require-dev": { 53 | "friendsofphp/php-cs-fixer": "dev-master", 54 | "phpunit/phpunit": "^11.5", 55 | "phpstan/phpstan": "^1.12.9", 56 | "phpstan/phpstan-phpunit": "^1.4.0", 57 | "phpstan/extension-installer": "^1.4.3", 58 | "phpstan/phpstan-deprecation-rules": "^1.2.1", 59 | "phpstan/phpstan-strict-rules": "^1.6.1", 60 | "infection/infection": "^0.29.14" 61 | }, 62 | "scripts": { 63 | "phpstan": "phpstan analyse", 64 | "fix-code-style": "php-cs-fixer fix", 65 | "build": [ 66 | "curl -Ls -o box.phar https://github.com/humbug/box/releases/download/4.6.1/box.phar", 67 | "composer install --no-dev", 68 | "php box.phar compile" 69 | ] 70 | }, 71 | "config": { 72 | "allow-plugins": { 73 | "phpstan/extension-installer": true, 74 | "infection/extension-installer": true, 75 | "php-http/discovery": true 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Command/LocalCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Run local danger on local git') 27 | ->addOption('root', null, InputOption::VALUE_OPTIONAL, 'Git Path', (string) getcwd()) 28 | ->addOption('head-branch', null, InputOption::VALUE_OPTIONAL, 'Head Branch') 29 | ->addOption('config', 'c', InputOption::VALUE_OPTIONAL, 'Path to Config file') 30 | ; 31 | } 32 | 33 | public function execute(InputInterface $input, OutputInterface $output): int 34 | { 35 | $configPath = $input->getOption('config'); 36 | $headBranch = $input->getOption('head-branch'); 37 | $root = $input->getOption('root'); 38 | 39 | if ($configPath !== null && !\is_string($configPath)) { 40 | throw new \InvalidArgumentException('Invalid config option given'); 41 | } 42 | 43 | if (!\is_string($root)) { 44 | throw new \InvalidArgumentException('Invalid root option given'); 45 | } 46 | 47 | if ($headBranch === null) { 48 | $process = new Process(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], $root); 49 | $process->mustRun(); 50 | $headBranch = trim($process->getOutput()); 51 | } 52 | 53 | $process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $root); 54 | $process->mustRun(); 55 | $localBranch = trim($process->getOutput()); 56 | 57 | $config = $this->configLoader->loadByPath($configPath); 58 | 59 | $this->localPlatform->load($root, $localBranch . '|' . $headBranch); 60 | 61 | $context = new Context($this->localPlatform); 62 | 63 | $this->runner->run($config, $context); 64 | 65 | return $this->handleReport($input, $output, $context); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | with: 13 | fetch-depth: '0' 14 | 15 | - name: Get the version 16 | id: get_version 17 | run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} 18 | 19 | - name: Get Changelog 20 | id: changelog 21 | run: | 22 | wget https://github.com/git-chglog/git-chglog/releases/download/v0.14.2/git-chglog_0.14.2_linux_amd64.tar.gz 23 | tar xf git-chglog_0.14.2_linux_amd64.tar.gz 24 | REPORT=$(./git-chglog ${{ steps.get_version.outputs.VERSION }}) 25 | REPORT="${REPORT//'%'/'%25'}" 26 | REPORT="${REPORT//$'\n'/'%0A'}" 27 | REPORT="${REPORT//$'\r'/'%0D'}" 28 | echo "::set-output name=CHANGELOG::$REPORT" 29 | 30 | - uses: shivammathur/setup-php@v2 31 | with: 32 | php-version: '8.2' 33 | 34 | - name: Set version string 35 | run: sed -i -e "s/__VERSION__/${{ steps.get_version.outputs.VERSION }}/g" src/Application.php 36 | 37 | - name: Build 38 | run: composer build 39 | 40 | - name: Create Release 41 | id: create_release 42 | uses: softprops/action-gh-release@v2 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | with: 46 | files: danger.phar 47 | tag_name: ${{ steps.get_version.outputs.VERSION }} 48 | name: ${{ steps.get_version.outputs.VERSION }} 49 | body: "${{ steps.changelog.outputs.CHANGELOG }}" 50 | draft: false 51 | prerelease: false 52 | 53 | - name: Login into Github Docker Registery 54 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin 55 | 56 | - name: Login into Docker Hub 57 | run: echo "${{ secrets.DOCKER_HUB_PAT }}" | docker login -u shyim --password-stdin 58 | 59 | - name: Set up QEMU 60 | uses: docker/setup-qemu-action@v3 61 | 62 | - name: Set up Docker Buildx 63 | uses: docker/setup-buildx-action@v3 64 | 65 | - name: Build and push 66 | uses: docker/build-push-action@v6 67 | with: 68 | push: true 69 | platforms: linux/amd64,linux/arm64 70 | context: . 71 | tags: | 72 | ghcr.io/shyim/danger-php:${{ steps.get_version.outputs.VERSION }} 73 | ghcr.io/shyim/danger-php:latest 74 | shyim/danger-php:${{ steps.get_version.outputs.VERSION }} 75 | shyim/danger-php:latest 76 | 77 | -------------------------------------------------------------------------------- /tests/ConfigTest.php: -------------------------------------------------------------------------------- 1 | useRule(static function (): void {}); 23 | static::assertCount(1, $config->getRules()); 24 | 25 | static::assertFalse($config->isThreadEnabled()); 26 | $config->useThreadOnFails(); /** @phpstan-ignore-line */ 27 | static::assertTrue($config->isThreadEnabled()); 28 | 29 | $config->useThreadOnFails(false); /** @phpstan-ignore-line */ 30 | $config->useThreadOn(Config::REPORT_LEVEL_WARNING); 31 | static::assertEquals(Config::REPORT_LEVEL_WARNING, $config->getUseThreadOn()); 32 | 33 | $config->useThreadOn(Config::REPORT_LEVEL_WARNING); 34 | static::assertEquals(Config::REPORT_LEVEL_WARNING, $config->getUseThreadOn()); 35 | 36 | $config->useThreadOn(Config::REPORT_LEVEL_NOTICE); 37 | static::assertEquals(Config::REPORT_LEVEL_NOTICE, $config->getUseThreadOn()); 38 | 39 | $config->useThreadOn(Config::REPORT_LEVEL_NONE); 40 | static::assertEquals(Config::REPORT_LEVEL_NONE, $config->getUseThreadOn()); 41 | 42 | static::assertNull($config->getGithubCommentProxy()); 43 | $config->useGithubCommentProxy('http://localhost'); 44 | static::assertSame('http://localhost', $config->getGithubCommentProxy()); 45 | 46 | static::assertSame(Config::UPDATE_COMMENT_MODE_UPDATE, $config->getUpdateCommentMode()); 47 | $config->useCommentMode(Config::UPDATE_COMMENT_MODE_REPLACE); 48 | static::assertSame(Config::UPDATE_COMMENT_MODE_REPLACE, $config->getUpdateCommentMode()); 49 | 50 | $config->after(static function (): void {}); 51 | static::assertCount(1, $config->getAfterHooks()); 52 | } 53 | 54 | public function testGetReportLevelNotice(): void 55 | { 56 | $config = new Config(); 57 | 58 | $context = new Context($this->createMock(AbstractPlatform::class)); 59 | $context->notice('test'); 60 | 61 | static::assertSame(Config::REPORT_LEVEL_NOTICE, $config->getReportLevel($context)); 62 | } 63 | 64 | public function testGetReportLevelNone(): void 65 | { 66 | $config = new Config(); 67 | 68 | $context = new Context($this->createMock(AbstractPlatform::class)); 69 | 70 | static::assertSame(Config::REPORT_LEVEL_NONE, $config->getReportLevel($context)); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /docs/context.md: -------------------------------------------------------------------------------- 1 | # Danger Context 2 | 3 | There is an overview of all information inside the Context 4 | 5 | ```php 6 | $context = new \Danger\Context(); 7 | 8 | $context->platform->addLabels('Label 1', 'Label 2'); // Allows adding label 9 | $context->platform->removeLabels('Label 1', 'Label 2'); // Allows removing label 10 | $context->platform->hasDangerMessage(); // Returns boolean that an danger comment already exists 11 | $context->platform->pullRequest->id; // Pull Request ID 12 | $context->platform->pullRequest->projectIdentifier; // Github: Owner/Repository, Gitlab: Project-ID 13 | $context->platform->pullRequest->title; // Pull Request Title 14 | $context->platform->pullRequest->body; // Body 15 | $context->platform->pullRequest->assignees; // Assignees 16 | $context->platform->pullRequest->reviewers; // Reviewers 17 | $context->platform->pullRequest->labels; // Labels 18 | $context->platform->pullRequest->createdAt; // Created At as \DateTime 19 | $context->platform->pullRequest->updatedAt; // Updated At as \DateTime 20 | $context->platform->raw; // Raw API Response 21 | $context->platform->pullRequest->rawCommits; // Raw API Commits (only available after getCommits() call) 22 | $context->platform->pullRequest->rawFiles; // Raw API Files (only available after getFiles() call) 23 | $context->platform->pullRequest->getCommits(); // Collection of commits 24 | $context->platform->pullRequest->getFileContent('phpstan-baseline.neon'); // Fetch a file content on that head commit 25 | 26 | $commit; // Element of commit collection 27 | $commit->author; // Commit author 28 | $commit->authorEmail; // Commit author email 29 | $commit->message; // Commit message 30 | $commit->sha; // Commit sha 31 | $commit->createdAt; // Created at as \DateTime 32 | $commit->verified; // Commit verified (gpg) 33 | 34 | $context->platform->pullRequest->getFiles(); // Collection of files 35 | 36 | $file; // Element of files collection 37 | $file->name; // File name 38 | $file->status; // File status can be added, modified, removed 39 | $file->additions; // Additions to the file as int 40 | $file->changes; // Changes to the file as int 41 | $file->deletions; // Deletions to the file as int 42 | $file->patch; // Git patch of this file 43 | $file->getContent(); // Retrieve the current content of the file 44 | 45 | $context->platform->pullRequest->getComments(); // Collection of comments 46 | 47 | $comment; // Element of comments collection 48 | $comment->author; // Comment author username 49 | $comment->body; // Comment body 50 | $comment->createdAt; // Comment createdAt 51 | $comment->updatedAt; // Comment updatedAt 52 | ``` 53 | 54 | ## Advanced 55 | 56 | The information above are available in all platforms. The platforms has also a public property `client` with the corresponding platform client to do custom api calls -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Danger PHP 2 | 3 | Danger runs during your CI process, and gives teams the chance to automate common code review chores. 4 | This project ports [Danger](https://danger.systems/ruby/) to PHP. 5 | 6 | Currently only GitHub and Gitlab are supported as Platform 7 | 8 | 9 | ## Badges 10 | 11 | [![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/shyim/danger-php/blob/main/LICENSE) 12 | [![codecov](https://codecov.io/gh/shyim/danger-php/branch/main/graph/badge.svg)](https://codecov.io/gh/shyim/danger-php) 13 | 14 | 15 | ## Installation 16 | 17 | ### Composer 18 | 19 | Install danger-php using Composer 20 | 21 | ```bash 22 | composer global require shyim/danger-php 23 | ``` 24 | 25 | ### Phar Archive 26 | 27 | Every release has a phar archive attached 28 | 29 | ### Docker 30 | 31 | Use the [prebuilt Docker image](https://github.com/users/shyim/packages/container/package/danger-php) 32 | 33 | ## Documentation 34 | 35 | - [Getting started](./docs/getting_started.md) 36 | - [Builtin Rules](./docs/builtin-rules.md) 37 | - [Danger Context](./docs/context.md) 38 | - [CI Integration](./docs/ci.md) 39 | - [Commands](./docs/commands.md) 40 | 41 | ### Disallow multiple commits with same message 42 | 43 | ```php 44 | useRule(new DisallowRepeatedCommits) // Disallows multiple commits with the same message 51 | ; 52 | ``` 53 | 54 | ### Only allow one commit in Pull Request 55 | 56 | ```php 57 | useRule(new MaxCommit(1)) 64 | ; 65 | 66 | 67 | ``` 68 | 69 | ### Check for modification on CHANGELOG.md 70 | 71 | ```php 72 | useRule(function (Context $context): void { 79 | if (!$context->platform->pullRequest->getFiles()->has('CHANGELOG.md')) { 80 | $context->failure('Please edit also the CHANGELOG.md'); 81 | } 82 | }) 83 | ; 84 | 85 | ``` 86 | 87 | ### Check for Assignee in PR 88 | 89 | ```php 90 | useRule(function (Context $context): void { 97 | if (count($context->platform->pullRequest->assignees) === 0) { 98 | $context->warning('This PR currently doesn\'t have an assignee'); 99 | } 100 | }) 101 | ; 102 | 103 | ``` 104 | 105 | ## Screenshots 106 | 107 | ![Example Comment](https://i.imgur.com/e2OEChE.png) 108 | 109 | 110 | ## License 111 | 112 | [MIT](https://choosealicense.com/licenses/mit/) 113 | 114 | 115 | -------------------------------------------------------------------------------- /docs/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | Before you can use Danger-PHP, you need to provide a ruleset. 4 | The default configuration can be generated using: 5 | 6 | ```shell 7 | # Composer global installed or Docker 8 | $ danger init 9 | # Phar 10 | $ php danger.phar init 11 | ``` 12 | 13 | This command generates the default `.danger.php` in the current directory. 14 | 15 | 16 | # Danger config 17 | 18 | The `.danger.php` file returns a `Danger\Config` object. This object accepts lot of configuration. 19 | 20 | ## useRule 21 | 22 | The `useRule` method can be used to add a Rule which should be executed in this Danger. 23 | See [builtin rules](./builtin-rules.md) for all included rules. 24 | This method also accepts a function consider you own changes. You will get the current Danger context as first parameter. 25 | Here is an example: 26 | 27 | ```php 28 | useRule(function (Context $context) { 35 | // if !$context->xxxx 36 | $context->failure('Conditions not matched'); 37 | }) 38 | ; 39 | ``` 40 | 41 | To see what the `Danger\Context` see [here](./context.md) 42 | 43 | ## after 44 | 45 | The `after` is similar to `useRule` but are intended to be executed as last steps. 46 | One example usage case could be to add labels when Danger is failed. 47 | 48 | ```php 49 | after(function (Context $context) { 56 | if ($context->hasFailures()) { 57 | $context->platform->addLabels('Incomplete'); 58 | } 59 | }) 60 | ; 61 | ``` 62 | 63 | ## useCommentMode 64 | 65 | With this option you can control that the message should be updated or replaced 66 | 67 | ```php 68 | useCommentMode(Config::UPDATE_COMMENT_MODE_REPLACE) // Replace the old comment 74 | ->useCommentMode(Config::UPDATE_COMMENT_MODE_UPDATE) // Update the old comment 75 | ; 76 | ``` 77 | 78 | ## useThreadOn 79 | 80 | **Currently only supported on GitLab** 81 | 82 | This option allows using a thread instead of a comment in the Pull Request. 83 | You can declare for which level of report you want to use a thread. 84 | 85 | Use thread if reports has at least one failure: 86 | ```php 87 | useThreadOn(Config::REPORT_LEVEL_FAILURE) 93 | ; 94 | ``` 95 | 96 | Use thread if reports has at least one warning: 97 | ```php 98 | useThreadOn(Config::REPORT_LEVEL_WARNING) 104 | ; 105 | ``` 106 | 107 | Use thread if reports has at least one notice: 108 | ```php 109 | useThreadOn(Config::REPORT_LEVEL_NOTICE) 115 | ; 116 | ``` 117 | -------------------------------------------------------------------------------- /tests/Platform/Gitlab/payloads/commits.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "2d7f9727fb1a786543df555bb55ad4febeeb2f2f", 4 | "short_id": "2d7f9727", 5 | "created_at": "2021-05-22T08:39:38.000Z", 6 | "parent_ids": [], 7 | "title": "Add new file", 8 | "message": "Add new file", 9 | "author_name": "Shyim", 10 | "author_email": "s.sayakci@gmail.com", 11 | "authored_date": "2021-05-22T08:39:38.000Z", 12 | "committer_name": "Shyim", 13 | "committer_email": "s.sayakci@gmail.com", 14 | "committed_date": "2021-05-22T08:39:38.000Z", 15 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/commit\/2d7f9727fb1a786543df555bb55ad4febeeb2f2f" 16 | }, 17 | { 18 | "id": "a83ad26873ae280e1890a136ecb82bf923939af4", 19 | "short_id": "a83ad268", 20 | "created_at": "2021-05-22T08:35:59.000Z", 21 | "parent_ids": [], 22 | "title": "Update .gitlab-ci.yml", 23 | "message": "Update .gitlab-ci.yml", 24 | "author_name": "Shyim", 25 | "author_email": "s.sayakci@gmail.com", 26 | "authored_date": "2021-05-22T08:35:59.000Z", 27 | "committer_name": "Shyim", 28 | "committer_email": "s.sayakci@gmail.com", 29 | "committed_date": "2021-05-22T08:35:59.000Z", 30 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/commit\/a83ad26873ae280e1890a136ecb82bf923939af4" 31 | }, 32 | { 33 | "id": "9310cbed54735a1a20a011fd53d2390f464d799d", 34 | "short_id": "9310cbed", 35 | "created_at": "2021-05-22T08:33:16.000Z", 36 | "parent_ids": [], 37 | "title": "Update .gitlab-ci.yml", 38 | "message": "Update .gitlab-ci.yml", 39 | "author_name": "Shyim", 40 | "author_email": "s.sayakci@gmail.com", 41 | "authored_date": "2021-05-22T08:33:16.000Z", 42 | "committer_name": "Shyim", 43 | "committer_email": "s.sayakci@gmail.com", 44 | "committed_date": "2021-05-22T08:33:16.000Z", 45 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/commit\/9310cbed54735a1a20a011fd53d2390f464d799d" 46 | }, 47 | { 48 | "id": "f7a4a32d282d03a18544a0b262e16fa0a020cc44", 49 | "short_id": "f7a4a32d", 50 | "created_at": "2021-05-22T08:30:47.000Z", 51 | "parent_ids": [], 52 | "title": "Add new file", 53 | "message": "Add new file", 54 | "author_name": "Shyim", 55 | "author_email": "s.sayakci@gmail.com", 56 | "authored_date": "2021-05-22T08:30:47.000Z", 57 | "committer_name": "Shyim", 58 | "committer_email": "s.sayakci@gmail.com", 59 | "committed_date": "2021-05-22T08:30:47.000Z", 60 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/commit\/f7a4a32d282d03a18544a0b262e16fa0a020cc44" 61 | }, 62 | { 63 | "id": "326d38502f56c64426decbca5a76a86d4a61834a", 64 | "short_id": "326d3850", 65 | "created_at": "2021-05-21T23:12:43.000Z", 66 | "parent_ids": [], 67 | "title": "Update Test", 68 | "message": "Update Test", 69 | "author_name": "Shyim", 70 | "author_email": "s.sayakci@gmail.com", 71 | "authored_date": "2021-05-21T23:12:43.000Z", 72 | "committer_name": "Shyim", 73 | "committer_email": "s.sayakci@gmail.com", 74 | "committed_date": "2021-05-21T23:12:43.000Z", 75 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/commit\/326d38502f56c64426decbca5a76a86d4a61834a" 76 | } 77 | ] -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 9 | ->setRules([ 10 | '@PSR12' => true, 11 | '@PSR12:risky' => true, 12 | '@PHP74Migration' => true, 13 | '@PHP74Migration:risky' => true, 14 | '@PhpCsFixer' => true, 15 | '@Symfony' => true, 16 | '@Symfony:risky' => true, 17 | // Ensure there is no code on the same line as the PHP open tag and it is followed by a blank line. 18 | 'blank_line_after_opening_tag' => false, 19 | // Using `isset($var) &&` multiple times should be done in one call. 20 | 'combine_consecutive_issets' => false, 21 | // Calling `unset` on multiple items should be done in one call. 22 | 'combine_consecutive_unsets' => false, 23 | // Concatenation should be spaced according configuration. 24 | 'concat_space' => ['spacing' => 'one'], 25 | // Pre- or post-increment and decrement operators should be used if possible. 26 | 'increment_style' => ['style' => 'post'], 27 | // Ensure there is no code on the same line as the PHP open tag. 28 | 'linebreak_after_opening_tag' => false, 29 | // Replace non multibyte-safe functions with corresponding mb function. 30 | 'mb_str_functions' => true, 31 | // Add leading `\` before function invocation to speed up resolving. 32 | 'native_function_invocation' => false, 33 | // Adds or removes `?` before type declarations for parameters with a default `null` value. 34 | 'nullable_type_declaration_for_default_null_value' => true, 35 | // All items of the given phpdoc tags must be either left-aligned or (by default) aligned vertically. 36 | 'phpdoc_align' => ['align' => 'left'], 37 | // PHPDoc summary should end in either a full stop, exclamation mark, or question mark. 38 | 'phpdoc_summary' => false, 39 | // Throwing exception must be done in single line. 40 | 'single_line_throw' => false, 41 | // Comparisons should be strict. 42 | 'strict_comparison' => true, 43 | // Functions should be used with `$strict` param set to `true`. 44 | 'strict_param' => true, 45 | // Anonymous functions with one-liner return statement must use arrow functions. 46 | 'use_arrow_functions' => false, 47 | // Write conditions in Yoda style (`true`), non-Yoda style (`['equal' => false, 'identical' => false, 'less_and_greater' => false]`) or ignore those conditions (`null`) based on configuration. 48 | 'yoda_style' => false, 49 | // Currently waiting for https://github.com/FriendsOfPHP/PHP-CS-Fixer/pull/5572 to be implemented to ignore @var (needed for LSP) 50 | 'phpdoc_to_comment' => false, 51 | 'php_unit_test_class_requires_covers' => false, 52 | ]) 53 | ->setFinder(PhpCsFixer\Finder::create() 54 | ->exclude('vendor') 55 | ->exclude('tests/fixtures') 56 | ->in(__DIR__) 57 | ) 58 | ; 59 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | rules[] = $closure; 35 | 36 | return $this; 37 | } 38 | 39 | public function after(callable $closure): static 40 | { 41 | $this->afterHooks[] = $closure; 42 | 43 | return $this; 44 | } 45 | 46 | /** 47 | * @return callable[] 48 | */ 49 | public function getRules(): array 50 | { 51 | return $this->rules; 52 | } 53 | 54 | /** 55 | * @return callable[] 56 | */ 57 | public function getAfterHooks(): array 58 | { 59 | return $this->afterHooks; 60 | } 61 | 62 | public function useCommentMode(string $mode): static 63 | { 64 | $this->updateCommentMode = $mode; 65 | 66 | return $this; 67 | } 68 | 69 | public function getUpdateCommentMode(): string 70 | { 71 | return $this->updateCommentMode; 72 | } 73 | 74 | public function useGithubCommentProxy(string $proxyUrl): static 75 | { 76 | $this->githubCommentProxyUrl = $proxyUrl; 77 | 78 | return $this; 79 | } 80 | 81 | public function getGithubCommentProxy(): ?string 82 | { 83 | return $this->githubCommentProxyUrl; 84 | } 85 | 86 | /** 87 | * @deprecated will be removed - use useThreadOn instead 88 | */ 89 | public function useThreadOnFails(bool $enable = true): static 90 | { 91 | $this->useThreadOn = $enable ? self::REPORT_LEVEL_FAILURE : self::REPORT_LEVEL_NONE; 92 | 93 | return $this; 94 | } 95 | 96 | public function useThreadOn(int $useTreadOn): static 97 | { 98 | $this->useThreadOn = $useTreadOn; 99 | 100 | return $this; 101 | } 102 | 103 | public function isThreadEnabled(): bool 104 | { 105 | return $this->useThreadOn > 0; 106 | } 107 | 108 | public function getUseThreadOn(): int 109 | { 110 | return $this->useThreadOn; 111 | } 112 | 113 | /** 114 | * Get the highest report level of the given context 115 | */ 116 | public function getReportLevel(Context $context): int 117 | { 118 | if ($context->hasFailures()) { 119 | return self::REPORT_LEVEL_FAILURE; 120 | } 121 | 122 | if ($context->hasWarnings()) { 123 | return self::REPORT_LEVEL_WARNING; 124 | } 125 | 126 | if ($context->hasNotices()) { 127 | return self::REPORT_LEVEL_NOTICE; 128 | } 129 | 130 | return self::REPORT_LEVEL_NONE; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /tests/Rule/DisallowRepeatedCommitsTest.php: -------------------------------------------------------------------------------- 1 | message = 'Test'; 23 | 24 | $secondCommit = new Commit(); 25 | $secondCommit->message = 'Test'; 26 | 27 | $github = $this->createMock(Github::class); 28 | $pr = $this->createMock(PullRequest::class); 29 | $pr->method('getCommits')->willReturn(new CommitCollection([$commit, $secondCommit])); 30 | $github->pullRequest = $pr; 31 | 32 | $context = new Context($github); 33 | 34 | $rule = new DisallowRepeatedCommits(); 35 | $rule($context); 36 | 37 | static::assertTrue($context->hasFailures()); 38 | } 39 | 40 | public function testRuleNotMatches(): void 41 | { 42 | $commit = new Commit(); 43 | $commit->message = 'Test'; 44 | 45 | $secondCommit = new Commit(); 46 | $secondCommit->message = 'Test2'; 47 | 48 | $github = $this->createMock(Github::class); 49 | $pr = $this->createMock(PullRequest::class); 50 | $pr->method('getCommits')->willReturn(new CommitCollection([$commit, $secondCommit])); 51 | $github->pullRequest = $pr; 52 | 53 | $context = new Context($github); 54 | 55 | $rule = new DisallowRepeatedCommits(); 56 | $rule($context); 57 | 58 | static::assertFalse($context->hasFailures()); 59 | } 60 | 61 | public function testRuleMatchesWithMergeCommits(): void 62 | { 63 | $commit = new Commit(); 64 | $commit->message = 'Test'; 65 | 66 | $secondCommit = new Commit(); 67 | $secondCommit->message = 'Test'; 68 | 69 | $thirdCommit = new Commit(); 70 | $thirdCommit->message = 'Merge branch master into feature'; 71 | 72 | $github = $this->createMock(Github::class); 73 | $pr = $this->createMock(PullRequest::class); 74 | $pr->method('getCommits')->willReturn(new CommitCollection([$commit, $secondCommit, $thirdCommit])); 75 | $github->pullRequest = $pr; 76 | 77 | $context = new Context($github); 78 | 79 | $rule = new DisallowRepeatedCommits(); 80 | $rule($context); 81 | 82 | static::assertTrue($context->hasFailures()); 83 | } 84 | 85 | public function testRuleNotMatchesWithMultipleMergeCommits(): void 86 | { 87 | $commit = new Commit(); 88 | $commit->message = 'Test'; 89 | 90 | $secondCommit = new Commit(); 91 | $secondCommit->message = 'Merge branch master into features'; 92 | 93 | $thirdCommit = new Commit(); 94 | $thirdCommit->message = 'Merge branch master into feature'; 95 | 96 | $github = $this->createMock(Github::class); 97 | $pr = $this->createMock(PullRequest::class); 98 | $pr->method('getCommits')->willReturn(new CommitCollection([$commit, $secondCommit, $thirdCommit])); 99 | $github->pullRequest = $pr; 100 | 101 | $context = new Context($github); 102 | 103 | $rule = new DisallowRepeatedCommits(); 104 | $rule($context); 105 | 106 | static::assertFalse($context->hasFailures()); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tests/Command/LocalCommandTest.php: -------------------------------------------------------------------------------- 1 | createMock(LocalPlatform::class)); 26 | 27 | static::expectException(\InvalidArgumentException::class); 28 | static::expectExceptionMessage('Invalid config option given'); 29 | 30 | $input = new ArgvInput(['danger']); 31 | $input->bind($cmd->getDefinition()); 32 | $input->setOption('config', []); 33 | 34 | $cmd->execute($input, new NullOutput()); 35 | } 36 | 37 | public function testInvalidRoot(): void 38 | { 39 | $cmd = new LocalCommand(new ConfigLoader(), new Runner(), $this->createMock(LocalPlatform::class)); 40 | 41 | static::expectException(\InvalidArgumentException::class); 42 | static::expectExceptionMessage('Invalid root option given'); 43 | 44 | $input = new ArgvInput(['danger']); 45 | $input->bind($cmd->getDefinition()); 46 | $input->setOption('root', []); 47 | 48 | $cmd->execute($input, new NullOutput()); 49 | } 50 | 51 | public function testCommand(): void 52 | { 53 | $tmpDir = sys_get_temp_dir() . '/' . uniqid('local', true); 54 | $tmpDirTarget = sys_get_temp_dir() . '/' . uniqid('local', true); 55 | 56 | mkdir($tmpDir); 57 | mkdir($tmpDirTarget); 58 | file_put_contents($tmpDir . '/a.txt', 'a'); 59 | 60 | (new Process(['git', 'init', '--bare', '-b', 'main'], $tmpDirTarget))->mustRun(); 61 | 62 | (new Process(['git', 'init', '-b', 'main'], $tmpDir))->mustRun(); 63 | (new Process(['git', 'config', 'commit.gpgsign', 'false'], $tmpDir))->mustRun(); 64 | (new Process(['git', 'config', 'user.name', 'PHPUnit'], $tmpDir))->mustRun(); 65 | (new Process(['git', 'config', 'user.email', 'unit@php.com'], $tmpDir))->mustRun(); 66 | (new Process(['git', 'branch', '-m', 'main'], $tmpDir))->mustRun(); 67 | (new Process(['git', 'add', 'a.txt'], $tmpDir))->mustRun(); 68 | (new Process(['git', 'commit', '-m', 'initial'], $tmpDir))->mustRun(); 69 | (new Process(['git', 'remote', 'add', 'origin', 'file://' . $tmpDirTarget], $tmpDir))->mustRun(); 70 | (new Process(['git', 'push', '-u', 'origin', 'main'], $tmpDir))->mustRun(); 71 | 72 | (new Filesystem())->remove($tmpDir); 73 | (new Process(['git', 'clone', 'file://' . $tmpDirTarget, $tmpDir]))->mustRun(); 74 | 75 | $cmd = new LocalCommand(new ConfigLoader(), new Runner(), $this->createMock(LocalPlatform::class)); 76 | 77 | $input = new ArgvInput(['danger', '--config=' . \dirname(__DIR__) . '/configs/empty.php', '--root=' . $tmpDir]); 78 | $input->bind($cmd->getDefinition()); 79 | 80 | $output = new BufferedOutput(); 81 | $returnCode = $cmd->execute($input, $output); 82 | 83 | static::assertStringContainsString('PR looks good', $output->fetch()); 84 | static::assertSame(0, $returnCode); 85 | 86 | (new Filesystem())->remove($tmpDir); 87 | (new Filesystem())->remove($tmpDirTarget); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/Command/CiCommandTest.php: -------------------------------------------------------------------------------- 1 | createMock(Github::class); 26 | $platform->expects(static::once())->method('removePost'); 27 | 28 | $detector = $this->createMock(PlatformDetector::class); 29 | $detector->method('detect')->willReturn($platform); 30 | 31 | $output = new BufferedOutput(); 32 | 33 | $cmd = new CiCommand($detector, new ConfigLoader(), new Runner(), new HTMLRenderer()); 34 | $returnCode = $cmd->run(new ArgvInput(['danger', '--config=' . \dirname(__DIR__) . '/configs/empty.php']), $output); 35 | 36 | static::assertSame(Command::SUCCESS, $returnCode); 37 | static::assertStringContainsString('Looks good!', $output->fetch()); 38 | } 39 | 40 | public function testNotValid(): void 41 | { 42 | $platform = $this->createMock(Github::class); 43 | $platform->method('post')->willReturn('https://danger.local/test'); 44 | 45 | $detector = $this->createMock(PlatformDetector::class); 46 | $detector->method('detect')->willReturn($platform); 47 | $output = new BufferedOutput(); 48 | 49 | $cmd = new CiCommand($detector, new ConfigLoader(), new Runner(), new HTMLRenderer()); 50 | $returnCode = $cmd->run(new ArgvInput(['danger', '--config=' . \dirname(__DIR__) . '/configs/all.php']), $output); 51 | 52 | static::assertSame(Command::FAILURE, $returnCode); 53 | static::assertStringContainsString('The comment has been created at https://danger.local/test', $output->fetch()); 54 | } 55 | 56 | public function testNotValidWarning(): void 57 | { 58 | $platform = $this->createMock(Github::class); 59 | $platform->method('post')->willReturn('https://danger.local/test'); 60 | 61 | $detector = $this->createMock(PlatformDetector::class); 62 | $detector->method('detect')->willReturn($platform); 63 | $output = new BufferedOutput(); 64 | 65 | $cmd = new CiCommand($detector, new ConfigLoader(), new Runner(), new HTMLRenderer()); 66 | $returnCode = $cmd->run(new ArgvInput(['danger', '--config=' . \dirname(__DIR__) . '/configs/warning.php']), $output); 67 | 68 | static::assertSame(0, $returnCode); 69 | static::assertStringContainsString('The comment has been created at https://danger.local/test', $output->fetch()); 70 | } 71 | 72 | public function testInvalidConfig(): void 73 | { 74 | $platform = $this->createMock(Github::class); 75 | $platform->method('post')->willReturn('https://danger.local/test'); 76 | 77 | $detector = $this->createMock(PlatformDetector::class); 78 | $detector->method('detect')->willReturn($platform); 79 | 80 | $cmd = new CiCommand($detector, new ConfigLoader(), new Runner(), new HTMLRenderer()); 81 | 82 | static::expectException(\InvalidArgumentException::class); 83 | static::expectExceptionMessage('Invalid config option given'); 84 | 85 | $input = new ArgvInput([]); 86 | $input->bind($cmd->getDefinition()); 87 | $input->setOption('config', []); 88 | 89 | $cmd->execute($input, new NullOutput()); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Platform/Gitlab/Gitlab.php: -------------------------------------------------------------------------------- 1 | projectIdentifier = $projectIdentifier; 25 | 26 | /** @var array{'sha': string, 'title': string, 'web_url': string, 'description': string|null, 'labels': string[], 'assignees': array{'username': string}[], 'reviewers': array{'username': string}[], 'created_at': string, 'updated_at': string} $res */ 27 | $res = $this->client->mergeRequests()->show($projectIdentifier, (int) $id); 28 | $this->raw = $res; 29 | 30 | $this->pullRequest = new PullRequest($this->client, $this->raw['sha']); 31 | $this->pullRequest->id = $id; 32 | $this->pullRequest->projectIdentifier = $projectIdentifier; 33 | $this->pullRequest->title = $this->raw['title']; 34 | $this->pullRequest->body = (string) $this->raw['description']; 35 | $this->pullRequest->labels = $this->raw['labels']; 36 | $this->pullRequest->assignees = array_map(static fn (array $assignee) => $assignee['username'], $this->raw['assignees']); 37 | $this->pullRequest->reviewers = array_map(static fn (array $reviewer) => $reviewer['username'], $this->raw['reviewers']); 38 | $this->pullRequest->createdAt = new \DateTime($this->raw['created_at']); 39 | $this->pullRequest->updatedAt = new \DateTime($this->raw['updated_at']); 40 | } 41 | 42 | public function post(string $body, Config $config): string 43 | { 44 | if ($config->isThreadEnabled()) { 45 | return $this->commenter->postThread( 46 | $this->projectIdentifier, 47 | (int) $this->pullRequest->id, 48 | $body, 49 | $config, 50 | $this->raw['web_url'] 51 | ); 52 | } 53 | 54 | return $this->commenter->postNote( 55 | $this->projectIdentifier, 56 | (int) $this->pullRequest->id, 57 | $body, 58 | $config, 59 | $this->raw['web_url'] 60 | ); 61 | } 62 | 63 | public function removePost(Config $config): void 64 | { 65 | if ($config->isThreadEnabled()) { 66 | $this->commenter->removeThread($this->projectIdentifier, (int) $this->pullRequest->id); 67 | 68 | return; 69 | } 70 | 71 | $this->commenter->removeNote($this->projectIdentifier, (int) $this->pullRequest->id); 72 | } 73 | 74 | public function addLabels(string ...$labels): void 75 | { 76 | parent::addLabels(...$labels); 77 | 78 | $this->client->mergeRequests()->update($this->projectIdentifier, (int) $this->pullRequest->id, [ 79 | 'labels' => implode(',', $this->pullRequest->labels), 80 | ]); 81 | } 82 | 83 | public function removeLabels(string ...$labels): void 84 | { 85 | parent::removeLabels(...$labels); 86 | 87 | $this->client->mergeRequests()->update($this->projectIdentifier, (int) $this->pullRequest->id, [ 88 | 'labels' => implode(',', $this->pullRequest->labels), 89 | ]); 90 | } 91 | 92 | public function hasDangerMessage(): bool 93 | { 94 | return \count($this->commenter->getRelevantNoteIds($this->projectIdentifier, (int) $this->pullRequest->id)) > 0; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Struct/Collection.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | abstract class Collection implements \IteratorAggregate, \Countable 12 | { 13 | /** 14 | * @var T[] 15 | */ 16 | protected array $elements = []; 17 | 18 | /** 19 | * @param iterable $elements 20 | */ 21 | final public function __construct(iterable $elements = []) 22 | { 23 | foreach ($elements as $key => $element) { 24 | $this->set($key, $element); 25 | } 26 | } 27 | 28 | /** 29 | * @param T $element 30 | */ 31 | public function add(mixed $element): void 32 | { 33 | $this->elements[] = $element; 34 | } 35 | 36 | /** 37 | * @param T $element 38 | */ 39 | public function set(string|int $key, mixed $element): void 40 | { 41 | $this->elements[$key] = $element; 42 | } 43 | 44 | /** 45 | * @return T|null 46 | */ 47 | public function get(string|int $key) 48 | { 49 | if ($this->has($key)) { 50 | return $this->elements[$key]; 51 | } 52 | 53 | return null; 54 | } 55 | 56 | public function clear(): void 57 | { 58 | $this->elements = []; 59 | } 60 | 61 | public function count(): int 62 | { 63 | return \count($this->elements); 64 | } 65 | 66 | /** 67 | * @return string[]|int[] 68 | */ 69 | public function getKeys(): array 70 | { 71 | return array_keys($this->elements); 72 | } 73 | 74 | public function has(string|int $key): bool 75 | { 76 | return \array_key_exists($key, $this->elements); 77 | } 78 | 79 | /** 80 | * @return mixed[] 81 | */ 82 | public function map(\Closure $closure): array 83 | { 84 | return array_map($closure, $this->elements); 85 | } 86 | 87 | public function reduce(\Closure $closure, mixed $initial = null): mixed 88 | { 89 | return array_reduce($this->elements, $closure, $initial); 90 | } 91 | 92 | /** 93 | * @return array 94 | */ 95 | public function fmap(\Closure $closure): array 96 | { 97 | return array_filter($this->map($closure)); 98 | } 99 | 100 | public function sort(\Closure $closure): void 101 | { 102 | uasort($this->elements, $closure); 103 | } 104 | 105 | /** 106 | * @return static 107 | */ 108 | public function filter(\Closure $closure): static 109 | { 110 | return $this->createNew(array_filter($this->elements, $closure)); 111 | } 112 | 113 | /** 114 | * @return static 115 | */ 116 | public function slice(int $offset, ?int $length = null): static 117 | { 118 | return $this->createNew(\array_slice($this->elements, $offset, $length, true)); 119 | } 120 | 121 | /** 122 | * @return T[] 123 | */ 124 | public function getElements(): array 125 | { 126 | return $this->elements; 127 | } 128 | 129 | /** 130 | * @return T|null 131 | */ 132 | public function first() 133 | { 134 | return array_values($this->elements)[0] ?? null; 135 | } 136 | 137 | /** 138 | * @return T|null 139 | */ 140 | public function last() 141 | { 142 | return array_values($this->elements)[\count($this->elements) - 1] ?? null; 143 | } 144 | 145 | public function remove(string $key): void 146 | { 147 | unset($this->elements[$key]); 148 | } 149 | 150 | /** 151 | * @return \Generator 152 | */ 153 | public function getIterator(): \Generator 154 | { 155 | yield from $this->elements; 156 | } 157 | 158 | /** 159 | * @param iterable $elements 160 | * 161 | * @return static 162 | */ 163 | protected function createNew(iterable $elements = []): static 164 | { 165 | return new static($elements); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /tests/Command/GitlabCommandTest.php: -------------------------------------------------------------------------------- 1 | createMock(Gitlab::class), new ConfigLoader(), new Runner()); 29 | $output = new BufferedOutput(); 30 | $cmd->run(new ArgvInput(['danger', 'test', '1']), $output); 31 | 32 | static::assertStringContainsString('DANGER_GITLAB_TOKEN ', $output->fetch()); 33 | } 34 | 35 | public function testInvalidConfig(): void 36 | { 37 | $_SERVER['DANGER_GITLAB_TOKEN'] = '1'; 38 | 39 | $cmd = new GitlabCommand($this->createMock(Gitlab::class), new ConfigLoader(), new Runner()); 40 | 41 | static::expectException(\InvalidArgumentException::class); 42 | static::expectExceptionMessage('Invalid config option given'); 43 | 44 | $input = new ArgvInput(['danger', 'https://github.com']); 45 | $input->bind($cmd->getDefinition()); 46 | $input->setArgument('projectIdentifier', 'test'); 47 | $input->setArgument('mrID', 'test'); 48 | $input->setOption('config', []); 49 | 50 | $cmd->execute($input, new NullOutput()); 51 | 52 | unset($_SERVER['DANGER_GITLAB_TOKEN']); 53 | } 54 | 55 | public function testValid(): void 56 | { 57 | $_SERVER['DANGER_GITLAB_TOKEN'] = '1'; 58 | 59 | $gitlab = $this->createMock(Gitlab::class); 60 | $gitlab 61 | ->expects(static::once()) 62 | ->method('load') 63 | ->with('test', '1') 64 | ; 65 | 66 | $cmd = new GitlabCommand($gitlab, new ConfigLoader(), new Runner()); 67 | 68 | $output = new BufferedOutput(); 69 | $returnCode = $cmd->run(new ArgvInput(['danger', 'test', '1', '--config=' . \dirname(__DIR__) . '/configs/empty.php']), $output); 70 | 71 | $text = $output->fetch(); 72 | 73 | static::assertStringContainsString('PR looks good', $text); 74 | static::assertSame(0, $returnCode); 75 | 76 | unset($_SERVER['DANGER_GITLAB_TOKEN']); 77 | } 78 | 79 | public function testValidWithErrors(): void 80 | { 81 | $_SERVER['DANGER_GITLAB_TOKEN'] = '1'; 82 | 83 | $gitlab = $this->createMock(Gitlab::class); 84 | $gitlab 85 | ->expects(static::once()) 86 | ->method('load') 87 | ->with('test', '1') 88 | ; 89 | 90 | $cmd = new GitlabCommand($gitlab, new ConfigLoader(), new Runner()); 91 | 92 | $tester = new CommandTester($cmd); 93 | $exitCode = $tester->execute([ 94 | 'mrID' => '1', 95 | 'projectIdentifier' => 'test', 96 | '--config' => \dirname(__DIR__) . '/configs/all.php', 97 | ]); 98 | 99 | static::assertSame(Command::FAILURE, $exitCode); 100 | static::assertStringContainsString('Failures', $tester->getDisplay()); 101 | static::assertStringContainsString('Warnings', $tester->getDisplay()); 102 | static::assertStringContainsString('Notices', $tester->getDisplay()); 103 | static::assertStringContainsString('A Failure', $tester->getDisplay()); 104 | static::assertStringContainsString('A Warning', $tester->getDisplay()); 105 | static::assertStringContainsString('A Notice', $tester->getDisplay()); 106 | 107 | unset($_SERVER['DANGER_GITLAB_TOKEN']); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/Platform/Github/payloads/commits.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "sha": "04911c4a084c06d8edac20cff34c236329175c66", 4 | "node_id": "MDY6Q29tbWl0MTYxODAzMjU3OjA0OTExYzRhMDg0YzA2ZDhlZGFjMjBjZmYzNGMyMzYzMjkxNzVjNjY=", 5 | "commit": { 6 | "author": { 7 | "name": "Soner Sayakci", 8 | "email": "s.sayakci@shopware.com", 9 | "date": "2021-05-20T20:20:49Z" 10 | }, 11 | "committer": { 12 | "name": "Soner Sayakci", 13 | "email": "s.sayakci@shopware.com", 14 | "date": "2021-05-20T21:44:42Z" 15 | }, 16 | "message": "fix(ci): Fix commit linting for external", 17 | "tree": { 18 | "sha": "4b31d3d9e8a5e082de5d28b063fc5d0a08b9706d", 19 | "url": "https://api.github.com/repos/FriendsOfShopware/FroshPluginUploader/git/trees/4b31d3d9e8a5e082de5d28b063fc5d0a08b9706d" 20 | }, 21 | "url": "https://api.github.com/repos/FriendsOfShopware/FroshPluginUploader/git/commits/04911c4a084c06d8edac20cff34c236329175c66", 22 | "comment_count": 0, 23 | "verification": { 24 | "verified": false, 25 | "reason": "unsigned", 26 | "signature": null, 27 | "payload": null 28 | } 29 | }, 30 | "url": "https://api.github.com/repos/FriendsOfShopware/FroshPluginUploader/commits/04911c4a084c06d8edac20cff34c236329175c66", 31 | "html_url": "https://github.com/FriendsOfShopware/FroshPluginUploader/commit/04911c4a084c06d8edac20cff34c236329175c66", 32 | "comments_url": "https://api.github.com/repos/FriendsOfShopware/FroshPluginUploader/commits/04911c4a084c06d8edac20cff34c236329175c66/comments", 33 | "author": { 34 | "login": "shyim", 35 | "id": 6224096, 36 | "node_id": "MDQ6VXNlcjYyMjQwOTY=", 37 | "avatar_url": "https://avatars.githubusercontent.com/u/6224096?v=4", 38 | "gravatar_id": "", 39 | "url": "https://api.github.com/users/shyim", 40 | "html_url": "https://github.com/shyim", 41 | "followers_url": "https://api.github.com/users/shyim/followers", 42 | "following_url": "https://api.github.com/users/shyim/following{/other_user}", 43 | "gists_url": "https://api.github.com/users/shyim/gists{/gist_id}", 44 | "starred_url": "https://api.github.com/users/shyim/starred{/owner}{/repo}", 45 | "subscriptions_url": "https://api.github.com/users/shyim/subscriptions", 46 | "organizations_url": "https://api.github.com/users/shyim/orgs", 47 | "repos_url": "https://api.github.com/users/shyim/repos", 48 | "events_url": "https://api.github.com/users/shyim/events{/privacy}", 49 | "received_events_url": "https://api.github.com/users/shyim/received_events", 50 | "type": "User", 51 | "site_admin": false 52 | }, 53 | "committer": { 54 | "login": "shyim", 55 | "id": 6224096, 56 | "node_id": "MDQ6VXNlcjYyMjQwOTY=", 57 | "avatar_url": "https://avatars.githubusercontent.com/u/6224096?v=4", 58 | "gravatar_id": "", 59 | "url": "https://api.github.com/users/shyim", 60 | "html_url": "https://github.com/shyim", 61 | "followers_url": "https://api.github.com/users/shyim/followers", 62 | "following_url": "https://api.github.com/users/shyim/following{/other_user}", 63 | "gists_url": "https://api.github.com/users/shyim/gists{/gist_id}", 64 | "starred_url": "https://api.github.com/users/shyim/starred{/owner}{/repo}", 65 | "subscriptions_url": "https://api.github.com/users/shyim/subscriptions", 66 | "organizations_url": "https://api.github.com/users/shyim/orgs", 67 | "repos_url": "https://api.github.com/users/shyim/repos", 68 | "events_url": "https://api.github.com/users/shyim/events{/privacy}", 69 | "received_events_url": "https://api.github.com/users/shyim/received_events", 70 | "type": "User", 71 | "site_admin": false 72 | }, 73 | "parents": [ 74 | { 75 | "sha": "a75f9fcc16f95511398453aef4be3d6032bc35df", 76 | "url": "https://api.github.com/repos/FriendsOfShopware/FroshPluginUploader/commits/a75f9fcc16f95511398453aef4be3d6032bc35df", 77 | "html_url": "https://github.com/FriendsOfShopware/FroshPluginUploader/commit/a75f9fcc16f95511398453aef4be3d6032bc35df" 78 | } 79 | ] 80 | } 81 | ] -------------------------------------------------------------------------------- /tests/Command/GithubCommandTest.php: -------------------------------------------------------------------------------- 1 | createMock(Github::class), new ConfigLoader(), new Runner()); 26 | 27 | static::expectException(\InvalidArgumentException::class); 28 | static::expectExceptionMessage('The given url must be a valid Github URL'); 29 | 30 | $cmd->run(new ArgvInput(['danger', $url]), new NullOutput()); 31 | } 32 | 33 | /** 34 | * @return array[] 35 | */ 36 | public static function invalidUrls(): array 37 | { 38 | return [ 39 | ['https://github.com'], 40 | ['testhttps://github.com'], 41 | ['testhttps://github.com/shyim/danger-php'], 42 | ['https://gitlab.com'], 43 | ]; 44 | } 45 | 46 | public function testInvalidConfig(): void 47 | { 48 | $cmd = new GithubCommand($this->createMock(Github::class), new ConfigLoader(), new Runner()); 49 | 50 | static::expectException(\InvalidArgumentException::class); 51 | static::expectExceptionMessage('Invalid config option given'); 52 | 53 | $input = new ArgvInput(['danger', 'https://github.com']); 54 | $input->bind($cmd->getDefinition()); 55 | $input->setOption('config', []); 56 | 57 | $cmd->execute($input, new NullOutput()); 58 | } 59 | 60 | public function testInvalidPr(): void 61 | { 62 | $cmd = new GithubCommand($this->createMock(Github::class), new ConfigLoader(), new Runner()); 63 | 64 | static::expectException(\InvalidArgumentException::class); 65 | static::expectExceptionMessage('The PR links needs to be a string'); 66 | 67 | $tester = new CommandTester($cmd); 68 | $tester->execute(['pr' => []]); 69 | } 70 | 71 | public function testValidUrlWithoutIssues(): void 72 | { 73 | $github = $this->createMock(Github::class); 74 | $github 75 | ->expects(static::once()) 76 | ->method('load') 77 | ->with('shyim/danger-php', '1') 78 | ; 79 | 80 | $tester = new CommandTester(new GithubCommand($github, new ConfigLoader(), new Runner())); 81 | 82 | $exitCode = $tester->execute(['pr' => 'https://github.com/shyim/danger-php/pull/1', '--config' => \dirname(__DIR__) . '/configs/empty.php']); 83 | static::assertSame(Command::SUCCESS, $exitCode); 84 | static::assertStringContainsString('PR looks good!', $tester->getDisplay()); 85 | } 86 | 87 | public function testValidUrlWithIssues(): void 88 | { 89 | $github = $this->createMock(Github::class); 90 | $github 91 | ->expects(static::once()) 92 | ->method('load') 93 | ->with('shyim/danger-php', '1') 94 | ; 95 | 96 | $cmd = new GithubCommand($github, new ConfigLoader(), new Runner()); 97 | $tester = new CommandTester($cmd); 98 | $exitCode = $tester->execute([ 99 | 'pr' => 'https://github.com/shyim/danger-php/pull/1', 100 | '--config' => \dirname(__DIR__) . '/configs/all.php', 101 | ]); 102 | 103 | static::assertSame(Command::FAILURE, $exitCode); 104 | static::assertStringContainsString('Failures', $tester->getDisplay()); 105 | static::assertStringContainsString('Warnings', $tester->getDisplay()); 106 | static::assertStringContainsString('Notices', $tester->getDisplay()); 107 | static::assertStringContainsString('A Failure', $tester->getDisplay()); 108 | static::assertStringContainsString('A Warning', $tester->getDisplay()); 109 | static::assertStringContainsString('A Notice', $tester->getDisplay()); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/Platform/Github/payloads/comments.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "url": "https://api.github.com/repos/FriendsOfShopware/FroshPluginUploader/issues/comments/845450556", 4 | "html_url": "https://github.com/FriendsOfShopware/FroshPluginUploader/pull/144#issuecomment-845450556", 5 | "issue_url": "https://api.github.com/repos/FriendsOfShopware/FroshPluginUploader/issues/144", 6 | "id": 845450556, 7 | "node_id": "MDEyOklzc3VlQ29tbWVudDg0NTQ1MDU1Ng==", 8 | "user": { 9 | "login": "codecov[bot]", 10 | "id": 22429695, 11 | "node_id": "MDM6Qm90MjI0Mjk2OTU=", 12 | "avatar_url": "https://avatars.githubusercontent.com/in/254?v=4", 13 | "gravatar_id": "", 14 | "url": "https://api.github.com/users/codecov%5Bbot%5D", 15 | "html_url": "https://github.com/apps/codecov", 16 | "followers_url": "https://api.github.com/users/codecov%5Bbot%5D/followers", 17 | "following_url": "https://api.github.com/users/codecov%5Bbot%5D/following{/other_user}", 18 | "gists_url": "https://api.github.com/users/codecov%5Bbot%5D/gists{/gist_id}", 19 | "starred_url": "https://api.github.com/users/codecov%5Bbot%5D/starred{/owner}{/repo}", 20 | "subscriptions_url": "https://api.github.com/users/codecov%5Bbot%5D/subscriptions", 21 | "organizations_url": "https://api.github.com/users/codecov%5Bbot%5D/orgs", 22 | "repos_url": "https://api.github.com/users/codecov%5Bbot%5D/repos", 23 | "events_url": "https://api.github.com/users/codecov%5Bbot%5D/events{/privacy}", 24 | "received_events_url": "https://api.github.com/users/codecov%5Bbot%5D/received_events", 25 | "type": "Bot", 26 | "site_admin": false 27 | }, 28 | "created_at": "2021-05-20T20:21:40Z", 29 | "updated_at": "2021-05-20T21:44:53Z", 30 | "author_association": "NONE", 31 | "body": "# [Codecov](https://codecov.io/gh/FriendsOfShopware/FroshPluginUploader/pull/144?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FriendsOfShopware) Report\n> Merging [#144](https://codecov.io/gh/FriendsOfShopware/FroshPluginUploader/pull/144?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FriendsOfShopware) (5d32fd9) into [master](https://codecov.io/gh/FriendsOfShopware/FroshPluginUploader/commit/a75f9fcc16f95511398453aef4be3d6032bc35df?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FriendsOfShopware) (a75f9fc) will **not change** coverage.\n> The diff coverage is `n/a`.\n\n> :exclamation: Current head 5d32fd9 differs from pull request most recent head 04911c4. Consider uploading reports for the commit 04911c4 to get more accurate results\n[![Impacted file tree graph](https://codecov.io/gh/FriendsOfShopware/FroshPluginUploader/pull/144/graphs/tree.svg?width=650&height=150&src=pr&token=AAMD57YI5V&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FriendsOfShopware)](https://codecov.io/gh/FriendsOfShopware/FroshPluginUploader/pull/144?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FriendsOfShopware)\n\n```diff\n@@ Coverage Diff @@\n## master #144 +/- ##\n========================================\n Coverage 0.00% 0.00% \n Complexity 390 390 \n========================================\n Files 39 39 \n Lines 894 894 \n========================================\n Misses 894 894 \n```\n\n\n\n------\n\n[Continue to review full report at Codecov](https://codecov.io/gh/FriendsOfShopware/FroshPluginUploader/pull/144?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FriendsOfShopware).\n> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FriendsOfShopware)\n> `Δ = absolute (impact)`, `ø = not affected`, `? = missing data`\n> Powered by [Codecov](https://codecov.io/gh/FriendsOfShopware/FroshPluginUploader/pull/144?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FriendsOfShopware). Last update [a75f9fc...04911c4](https://codecov.io/gh/FriendsOfShopware/FroshPluginUploader/pull/144?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FriendsOfShopware). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=FriendsOfShopware).\n", 32 | "performed_via_github_app": null 33 | } 34 | ] -------------------------------------------------------------------------------- /src/Platform/Github/GithubCommenter.php: -------------------------------------------------------------------------------- 1 | getGithubCommentProxy() !== null) { 21 | return $this->commentUsingProxy($owner, $repo, $id, $body, $config); 22 | } 23 | 24 | return $this->commentUsingApiKey($owner, $repo, $id, $body, $config); 25 | } 26 | 27 | private function commentUsingProxy(string $owner, string $repo, string $id, string $body, Config $config): string 28 | { 29 | $url = sprintf('%s/repos/%s/%s/issues/%s/comments', $config->getGithubCommentProxy(), $owner, $repo, $id); 30 | $response = $this->httpClient->request('POST', $url, [ 31 | 'json' => ['body' => $body, 'mode' => $config->getUpdateCommentMode()], 32 | 'headers' => [ 33 | 'User-Agent' => 'Comment-Proxy', 34 | 'temporary-github-token' => $_SERVER['GITHUB_TOKEN'], 35 | ], 36 | ])->toArray(); 37 | 38 | if (!isset($response['html_url'])) { 39 | throw new \UnexpectedValueException(sprintf('Expected html_url in the response. But got %s', json_encode($response, \JSON_THROW_ON_ERROR))); 40 | } 41 | 42 | return $response['html_url']; 43 | } 44 | 45 | private function commentUsingApiKey(string $owner, string $repo, string $id, string $body, Config $config): string 46 | { 47 | $ids = $this->getCommentIds($owner, $repo, $id); 48 | 49 | /** 50 | * Delete all comments and create a new one 51 | */ 52 | if ($config->getUpdateCommentMode() === Config::UPDATE_COMMENT_MODE_REPLACE) { 53 | foreach ($ids as $commentId) { 54 | $this->client->issues()->comments()->remove($owner, $repo, $commentId); 55 | } 56 | 57 | $comment = $this->client->issues()->comments()->create($owner, $repo, (int) $id, ['body' => $body]); 58 | 59 | return $comment['html_url']; 60 | } 61 | 62 | /** 63 | * Could not find any comment. Lets create a new one 64 | */ 65 | if (\count($ids) === 0) { 66 | $comment = $this->client->issues()->comments()->create($owner, $repo, (int) $id, ['body' => $body]); 67 | 68 | return $comment['html_url']; 69 | } 70 | 71 | $url = ''; 72 | 73 | /** 74 | * Update first comment, delete all other 75 | */ 76 | foreach ($ids as $i => $commentId) { 77 | if ($i === 0) { 78 | $comment = $this->client->issues()->comments()->update($owner, $repo, $commentId, ['body' => $body]); 79 | 80 | $url = $comment['html_url']; 81 | continue; 82 | } 83 | 84 | $this->client->issues()->comments()->remove($owner, $repo, $commentId); 85 | } 86 | 87 | return $url; 88 | } 89 | 90 | /** 91 | * @return int[] 92 | */ 93 | public function getCommentIds(string $owner, string $repo, string $id): array 94 | { 95 | $ids = []; 96 | 97 | $pager = new ResultPager($this->client); 98 | $comments = $pager->fetchAll($this->client->issues()->comments(), 'all', [$owner, $repo, (int) $id]); 99 | 100 | foreach ($comments as $comment) { 101 | if (str_contains($comment['body'], HTMLRenderer::MARKER)) { 102 | $ids[] = (int) $comment['id']; 103 | } 104 | } 105 | 106 | return $ids; 107 | } 108 | 109 | public function remove(string $owner, string $repo, string $id, Config $config): void 110 | { 111 | if ($config->getGithubCommentProxy() !== null) { 112 | $url = sprintf('%s/repos/%s/%s/issues/%s/comments', $config->getGithubCommentProxy(), $owner, $repo, $id); 113 | $this->httpClient->request('POST', $url, [ 114 | 'json' => ['body' => 'delete', 'mode' => $config->getUpdateCommentMode()], 115 | 'headers' => [ 116 | 'User-Agent' => 'Comment-Proxy', 117 | 'temporary-github-token' => $_SERVER['GITHUB_TOKEN'], 118 | ], 119 | ])->toArray(); 120 | 121 | return; 122 | } 123 | 124 | $ids = $this->getCommentIds($owner, $repo, $id); 125 | 126 | foreach ($ids as $commentId) { 127 | $this->client->issues()->comments()->remove($owner, $repo, $commentId); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/Struct/FileCollectionTest.php: -------------------------------------------------------------------------------- 1 | name = 'CHANGELOG.md'; 19 | $f->status = FileAlias::STATUS_ADDED; 20 | 21 | $c = new FileCollection([$f]); 22 | 23 | static::assertCount(1, $c->filterStatus(FileAlias::STATUS_ADDED)); 24 | 25 | $f->status = FileAlias::STATUS_MODIFIED; 26 | 27 | static::assertCount(1, $c->filterStatus(FileAlias::STATUS_MODIFIED)); 28 | 29 | $f->status = FileAlias::STATUS_REMOVED; 30 | 31 | static::assertCount(1, $c->filterStatus(FileAlias::STATUS_REMOVED)); 32 | } 33 | 34 | public function testClear(): void 35 | { 36 | $f = new FakeFile(''); 37 | $f->name = 'CHANGELOG.md'; 38 | $f->status = FileAlias::STATUS_ADDED; 39 | 40 | $c = new FileCollection([$f]); 41 | 42 | static::assertCount(1, $c); 43 | $c->clear(); 44 | static::assertCount(0, $c); 45 | } 46 | 47 | public function testGet(): void 48 | { 49 | $f = new FakeFile(''); 50 | $c = new FileCollection([$f]); 51 | static::assertSame($f, $c->get('0')); 52 | static::assertNull($c->get('1')); 53 | static::assertCount(1, $c->getKeys()); 54 | static::assertCount(1, $c->getElements()); 55 | $c->remove('0'); 56 | static::assertCount(0, $c->getElements()); 57 | } 58 | 59 | public function testSlice(): void 60 | { 61 | $f = new FakeFile(''); 62 | $c = new FileCollection([$f]); 63 | 64 | static::assertCount(0, $c->slice(1)); 65 | static::assertCount(1, $c->slice(0)); 66 | } 67 | 68 | public function testReduce(): void 69 | { 70 | $f = new FakeFile(''); 71 | $c = new FileCollection([$f]); 72 | 73 | $bool = $c->reduce(static fn (): bool => true); 74 | 75 | static::assertTrue($bool); 76 | } 77 | 78 | public function testSort(): void 79 | { 80 | $f1 = new FakeFile(''); 81 | $f1->name = 'A'; 82 | 83 | $f2 = new FakeFile(''); 84 | $f2->name = 'Z'; 85 | 86 | $c = new FileCollection([$f2, $f1]); 87 | 88 | $c->sort(static fn (FakeFile $a, FakeFile $b): int => $a->name <=> $b->name); 89 | 90 | $file = $c->first(); 91 | static::assertInstanceOf(FakeFile::class, $file); 92 | 93 | static::assertSame('A', $file->name); 94 | } 95 | 96 | public function testFilesMatching(): void 97 | { 98 | $f1 = new FakeFile(''); 99 | $f1->name = 'README.md'; 100 | 101 | $f2 = new FakeFile(''); 102 | $f2->name = 'changelogs/_unreleased/some-file.md'; 103 | 104 | $f3 = new FakeFile(''); 105 | $f3->name = 'src/Test.php'; 106 | 107 | $c = new FileCollection([$f1, $f2, $f3]); 108 | 109 | $newCollection = $c->matches('changelogs/**/*.md'); 110 | 111 | static::assertCount(1, $newCollection); 112 | $item = $newCollection->first(); 113 | static::assertInstanceOf(FakeFile::class, $item); 114 | static::assertSame('changelogs/_unreleased/some-file.md', $item->name); 115 | } 116 | 117 | public function testFilesMatchingContent(): void 118 | { 119 | $f1 = new FakeFile('./tests/fixtures/README.md'); 120 | $f1->name = 'tests/fixtures/README.md'; 121 | 122 | $f2 = new FakeFile('./tests/fixtures/SqlHeredocFixture.php'); 123 | $f2->name = 'tests/fixtures/SqlHeredocFixture.php'; 124 | 125 | $f3 = new FakeFile('./tests/fixtures/SqlNowdocFixture.php'); 126 | $f3->name = 'tests/fixtures/SqlNowdocFixture.php'; 127 | 128 | $c = new FileCollection([$f1, $f2, $f3]); 129 | 130 | $newCollection = $c->matchesContent('/<<first(); 134 | static::assertInstanceOf(FakeFile::class, $item); 135 | static::assertSame('tests/fixtures/SqlHeredocFixture.php', $item->name); 136 | } 137 | 138 | public function testMap(): void 139 | { 140 | $f1 = new FakeFile(''); 141 | $f1->name = 'A'; 142 | 143 | $c = new FileCollection([$f1]); 144 | $list = $c->fmap(fn (FakeFile $file) => $file->name); 145 | 146 | static::assertSame(['A'], $list); 147 | } 148 | } 149 | 150 | /** 151 | * @internal 152 | */ 153 | class FakeFile extends FileAlias 154 | { 155 | public function __construct(private string $fileName) 156 | { 157 | } 158 | 159 | public function getContent(): string 160 | { 161 | return (string) file_get_contents($this->fileName); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Struct/Github/PullRequest.php: -------------------------------------------------------------------------------- 1 | |null 21 | */ 22 | private ?CommitCollection $commits = null; 23 | 24 | /** 25 | * @var FileCollection|null 26 | */ 27 | private ?FileCollection $files = null; 28 | 29 | /** 30 | * @var CommentCollection|null 31 | */ 32 | private ?CommentCollection $comments = null; 33 | 34 | /** 35 | * @var list 36 | */ 37 | public array $rawFiles = []; 38 | 39 | public function __construct(private GithubClient $client, private string $owner, private string $repo, private string $headSha) 40 | { 41 | } 42 | 43 | public function getCommits(): CommitCollection 44 | { 45 | if ($this->commits !== null) { 46 | return $this->commits; 47 | } 48 | 49 | $this->rawCommits = $this->client->pullRequest()->commits($this->owner, $this->repo, $this->id); 50 | 51 | $collection = new CommitCollection(); 52 | 53 | foreach ($this->rawCommits as $rawGithubCommit) { 54 | $commit = new Commit(); 55 | $commit->sha = $rawGithubCommit['sha']; 56 | $commit->createdAt = new \DateTime($rawGithubCommit['commit']['committer']['date']); 57 | $commit->message = $rawGithubCommit['commit']['message']; 58 | $commit->author = $rawGithubCommit['commit']['committer']['name']; 59 | $commit->authorEmail = $rawGithubCommit['commit']['committer']['email']; 60 | $commit->verified = $rawGithubCommit['commit']['verification']['verified']; 61 | 62 | $collection->add($commit); 63 | } 64 | 65 | return $this->commits = $collection; 66 | } 67 | 68 | public function getFile(string $fileName): File 69 | { 70 | return new GithubFile($this->client, $this->owner, $this->repo, $fileName, $this->headSha); 71 | } 72 | 73 | public function getFiles(): FileCollection 74 | { 75 | if ($this->files !== null) { 76 | return $this->files; 77 | } 78 | 79 | $this->rawFiles = (new ResultPager($this->client)) 80 | ->fetchAll($this->client->pullRequest(), 'files', [$this->owner, $this->repo, $this->id]) 81 | ; 82 | 83 | $collection = new FileCollection(); 84 | 85 | foreach ($this->rawFiles as $rawGithubFile) { 86 | $file = new GithubFile($this->client, $this->owner, $this->repo, $rawGithubFile['filename'], $this->headSha); 87 | $file->name = $rawGithubFile['filename']; 88 | $file->status = $rawGithubFile['status']; 89 | $file->additions = $rawGithubFile['additions']; 90 | $file->deletions = $rawGithubFile['deletions']; 91 | $file->changes = $rawGithubFile['changes']; 92 | 93 | if (isset($rawGithubFile['patch'])) { 94 | $file->patch = $rawGithubFile['patch']; 95 | } 96 | 97 | $collection->set($file->name, $file); 98 | } 99 | 100 | return $this->files = $collection; 101 | } 102 | 103 | public function getComments(): CommentCollection 104 | { 105 | if ($this->comments !== null) { 106 | return $this->comments; 107 | } 108 | 109 | $pager = new ResultPager($this->client); 110 | $list = $pager->fetchAll($this->client->pullRequest()->comments(), 'all', [$this->owner, $this->repo, $this->id]); 111 | $this->comments = new CommentCollection(); 112 | 113 | foreach ($list as $commentArray) { 114 | $comment = new Comment(); 115 | $comment->author = $commentArray['user']['login']; 116 | $comment->body = $commentArray['body']; 117 | $comment->createdAt = new \DateTime($commentArray['created_at']); 118 | $comment->updatedAt = new \DateTime($commentArray['updated_at']); 119 | 120 | $this->comments->add($comment); 121 | } 122 | 123 | return $this->comments; 124 | } 125 | 126 | public function getFileContent(string $path): string 127 | { 128 | try { 129 | // @phpstan-ignore-next-line 130 | return $this->client->repo()->contents()->rawDownload($this->owner, $this->repo, $path, $this->headSha); 131 | } catch (\Throwable $e) { 132 | throw new CouldNotGetFileContentException($path, $e); 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Platform/Github/Github.php: -------------------------------------------------------------------------------- 1 | githubOwner = $owner; 28 | $this->githubRepository = $repository; 29 | 30 | /** @var array{'title': string, 'body': ?string, 'labels': array{'name': string}[], 'assignees': array{'login': string}[], 'requested_reviewers': array{'login': string}[], 'created_at': string, 'updated_at': string, head: array{sha: string}} $raw */ 31 | $raw = $this->client->pullRequest()->show($owner, $repository, (int) $id); 32 | $this->raw = $raw; 33 | 34 | $this->pullRequest = new GithubPullRequest($this->client, $owner, $repository, $raw['head']['sha']); 35 | $this->pullRequest->id = $id; 36 | $this->pullRequest->projectIdentifier = $projectIdentifier; 37 | $this->pullRequest->title = $this->raw['title']; 38 | $this->pullRequest->body = $this->raw['body'] ?? ''; 39 | $this->pullRequest->labels = array_map(static fn (array $label): string => $label['name'], $this->raw['labels'] 40 | ); 41 | $this->pullRequest->assignees = array_map(static fn (array $assignee): string => $assignee['login'], $this->raw['assignees'] 42 | ); 43 | $this->pullRequest->reviewers = $this->getReviews($owner, $repository, $id); 44 | $this->pullRequest->createdAt = new \DateTime($this->raw['created_at']); 45 | $this->pullRequest->updatedAt = new \DateTime($this->raw['updated_at']); 46 | } 47 | 48 | public function post(string $body, Config $config): string 49 | { 50 | return $this->commenter->comment( 51 | $this->githubOwner, 52 | $this->githubRepository, 53 | $this->pullRequest->id, 54 | $body, 55 | $config 56 | ); 57 | } 58 | 59 | public function removePost(Config $config): void 60 | { 61 | $this->commenter->remove( 62 | $this->githubOwner, 63 | $this->githubRepository, 64 | $this->pullRequest->id, 65 | $config 66 | ); 67 | } 68 | 69 | public function addLabels(string ...$labels): void 70 | { 71 | parent::addLabels(...$labels); 72 | 73 | try { 74 | $this->client->issues()->update( 75 | $this->githubOwner, 76 | $this->githubRepository, 77 | (int) $this->pullRequest->id, 78 | [ 79 | 'labels' => $this->pullRequest->labels, 80 | ] 81 | ); 82 | } catch (\Throwable $e) { 83 | if (str_contains($e->getMessage(), 'Resource not accessible by integration')) { 84 | return; 85 | } 86 | 87 | throw $e; 88 | } 89 | } 90 | 91 | public function removeLabels(string ...$labels): void 92 | { 93 | parent::removeLabels(...$labels); 94 | 95 | try { 96 | $this->client->issues()->update( 97 | $this->githubOwner, 98 | $this->githubRepository, 99 | (int) $this->pullRequest->id, 100 | [ 101 | 'labels' => $this->pullRequest->labels, 102 | ] 103 | ); 104 | } catch (\Throwable $e) { 105 | if (str_contains($e->getMessage(), 'Resource not accessible by integration')) { 106 | return; 107 | } 108 | 109 | throw $e; 110 | } 111 | } 112 | 113 | /** 114 | * @return string[] 115 | */ 116 | private function getReviews(string $owner, string $repository, string $id): array 117 | { 118 | $requestedReviewers = array_map(static fn (array $reviewer): string => $reviewer['login'], $this->raw['requested_reviewers']); 119 | 120 | $reviewersRequest = $this->client->pullRequest()->reviews()->all($owner, $repository, (int) $id); 121 | $reviewers = array_map(static fn (array $reviewer) => $reviewer['user']['login'], $reviewersRequest); 122 | 123 | return array_unique(array_merge($requestedReviewers, $reviewers)); 124 | } 125 | 126 | public function hasDangerMessage(): bool 127 | { 128 | return \count($this->commenter->getCommentIds($this->githubOwner, $this->githubRepository, $this->pullRequest->id)) > 0; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Struct/Local/LocalPullRequest.php: -------------------------------------------------------------------------------- 1 | |null 18 | */ 19 | private ?CommitCollection $commits = null; 20 | 21 | /** 22 | * @var FileCollection|null 23 | */ 24 | private ?FileCollection $files = null; 25 | 26 | public function __construct(private string $repo, private string $local, private string $target) 27 | { 28 | $this->projectIdentifier = $this->local; 29 | $this->id = $this->local; 30 | $this->body = ''; 31 | 32 | $commits = $this->getCommits(); 33 | 34 | if ($commits->count() > 0) { 35 | $firstCommit = $commits->first(); 36 | \assert($firstCommit !== null); 37 | $this->title = $firstCommit->message; 38 | $this->createdAt = $firstCommit->createdAt; 39 | $this->updatedAt = $firstCommit->createdAt; 40 | } else { 41 | $this->title = 'empty'; 42 | $this->createdAt = new \DateTime(); 43 | $this->updatedAt = new \DateTime(); 44 | } 45 | } 46 | 47 | public function getCommits(): CommitCollection 48 | { 49 | if ($this->commits !== null) { 50 | return $this->commits; 51 | } 52 | 53 | $process = new Process([ 54 | 'git', 55 | 'log', 56 | '--pretty=format:{%n "commit": "%H",%n "abbreviated_commit": "%h",%n "tree": "%T",%n "abbreviated_tree": "%t",%n "parent": "%P",%n "abbreviated_parent": "%p",%n "refs": "%D",%n "encoding": "%e",%n "subject": "%s",%n "sanitized_subject_line": "%f",%n "body": "%b",%n "commit_notes": "%N",%n "verification_flag": "%G?",%n "signer": "%GS",%n "signer_key": "%GK",%n "author": {%n "name": "%aN",%n "email": "%aE",%n "date": "%aD"%n },%n "commiter": {%n "name": "%cN",%n "email": "%cE",%n "date": "%cD"%n }%n},', 57 | $this->target . '..' . $this->local, 58 | ], $this->repo); 59 | 60 | $process->mustRun(); 61 | 62 | $commits = new CommitCollection(); 63 | 64 | /** @var array{commit: string, author: array{name: string, email: string, date: string}, subject: string}[] $gitOutput */ 65 | $gitOutput = json_decode('[' . mb_substr($process->getOutput(), 0, -1) . ']', true); 66 | 67 | foreach ($gitOutput as $commit) { 68 | $commitObj = new Commit(); 69 | $commitObj->sha = $commit['commit']; 70 | $commitObj->author = $commit['author']['name']; 71 | $commitObj->authorEmail = $commit['author']['email']; 72 | $commitObj->message = $commit['subject']; 73 | $commitObj->createdAt = new \DateTime($commit['author']['date']); 74 | 75 | $commits->add($commitObj); 76 | } 77 | 78 | return $this->commits = $commits; 79 | } 80 | 81 | public function getFile(string $fileName): File 82 | { 83 | return new LocalFile($this->repo . '/' . $fileName); 84 | } 85 | 86 | public function getFiles(): FileCollection 87 | { 88 | if ($this->files !== null) { 89 | return $this->files; 90 | } 91 | 92 | $process = new Process([ 93 | 'git', 94 | 'diff', 95 | $this->target . '..' . $this->local, 96 | '--name-status', 97 | ], $this->repo); 98 | 99 | $process->mustRun(); 100 | 101 | $files = new FileCollection(); 102 | 103 | foreach (explode(\PHP_EOL, $process->getOutput()) as $line) { 104 | if ($line === '') { 105 | continue; 106 | } 107 | 108 | $status = $line[0]; 109 | $file = trim(mb_substr($line, 1)); 110 | 111 | $element = new LocalFile($this->repo . '/' . $file); 112 | $element->name = $file; 113 | $element->additions = 0; 114 | $element->changes = 0; 115 | $element->deletions = 0; 116 | 117 | if ($status === 'A') { 118 | $element->status = File::STATUS_ADDED; 119 | } elseif ($status === 'M') { 120 | $element->status = File::STATUS_MODIFIED; 121 | } else { 122 | $element->status = File::STATUS_REMOVED; 123 | } 124 | 125 | $files->set($element->name, $element); 126 | } 127 | 128 | return $this->files = $files; 129 | } 130 | 131 | public function getComments(): CommentCollection 132 | { 133 | return new CommentCollection(); 134 | } 135 | 136 | public function getFileContent(string $path): string 137 | { 138 | $file = $this->repo . '/' . $path; 139 | 140 | if (!file_exists($file)) { 141 | throw new CouldNotGetFileContentException($path); 142 | } 143 | 144 | return (string) file_get_contents($file); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /tests/Struct/Local/LocalPullRequestTest.php: -------------------------------------------------------------------------------- 1 | tmpDir = sys_get_temp_dir() . '/' . uniqid('local', true); 22 | 23 | mkdir($this->tmpDir); 24 | file_put_contents($this->tmpDir . '/a.txt', 'a'); 25 | file_put_contents($this->tmpDir . '/b.txt', 'b'); 26 | file_put_contents($this->tmpDir . '/c.txt', 'c'); 27 | file_put_contents($this->tmpDir . '/modified.txt', 'a'); 28 | 29 | (new Process(['git', 'init'], $this->tmpDir))->mustRun(); 30 | (new Process(['git', 'config', 'commit.gpgsign', 'false'], $this->tmpDir))->mustRun(); 31 | (new Process(['git', 'config', 'user.name', 'PHPUnit'], $this->tmpDir))->mustRun(); 32 | (new Process(['git', 'config', 'user.email', 'unit@php.com'], $this->tmpDir))->mustRun(); 33 | (new Process(['git', 'branch', '-m', 'main'], $this->tmpDir))->mustRun(); 34 | (new Process(['git', 'add', 'a.txt'], $this->tmpDir))->mustRun(); 35 | (new Process(['git', 'add', 'modified.txt'], $this->tmpDir))->mustRun(); 36 | (new Process(['git', 'commit', '-m', 'initial'], $this->tmpDir))->mustRun(); 37 | 38 | (new Process(['git', 'checkout', '-b', 'feature'], $this->tmpDir))->mustRun(); 39 | (new Process(['git', 'add', 'b.txt'], $this->tmpDir))->mustRun(); 40 | (new Process(['git', 'commit', '-m', 'feature'], $this->tmpDir))->mustRun(); 41 | 42 | (new Process(['git', 'checkout', '-b', 'feature2'], $this->tmpDir))->mustRun(); 43 | (new Process(['git', 'rm', 'a.txt'], $this->tmpDir))->mustRun(); 44 | file_put_contents($this->tmpDir . '/b.txt', 'b2'); 45 | file_put_contents($this->tmpDir . '/modified.txt', 'b'); 46 | (new Process(['git', 'add', 'b.txt', 'c.txt', 'modified.txt'], $this->tmpDir))->mustRun(); 47 | 48 | (new Process(['git', 'commit', '-m', 'all modes'], $this->tmpDir))->mustRun(); 49 | } 50 | 51 | protected function tearDown(): void 52 | { 53 | parent::tearDown(); 54 | (new Filesystem())->remove($this->tmpDir); 55 | } 56 | 57 | public function testCommits(): void 58 | { 59 | $pr = new LocalPullRequest($this->tmpDir, 'feature', 'main'); 60 | static::assertSame('feature', $pr->title); 61 | 62 | $commits = $pr->getCommits(); 63 | 64 | static::assertCount(1, $commits); 65 | 66 | $commit = $commits->first(); 67 | static::assertNotNull($commit); 68 | 69 | static::assertSame('feature', $commit->message); 70 | static::assertSame('PHPUnit', $commit->author); 71 | static::assertSame('unit@php.com', $commit->authorEmail); 72 | static::assertEqualsWithDelta(time(), $commit->createdAt->getTimestamp(), 1); 73 | } 74 | 75 | public function testEmptyCommits(): void 76 | { 77 | $pr = new LocalPullRequest($this->tmpDir, 'main', 'main'); 78 | static::assertSame('empty', $pr->title); 79 | 80 | static::assertCount(0, $pr->getCommits()); 81 | static::assertCount(0, $pr->getFiles()); 82 | static::assertCount(0, $pr->getComments()); 83 | } 84 | 85 | public function testGetFiles(): void 86 | { 87 | $pr = new LocalPullRequest($this->tmpDir, 'feature2', 'main'); 88 | 89 | $files = $pr->getFiles(); 90 | 91 | $files2 = $pr->getFiles(); 92 | 93 | static::assertSame($files, $files2); 94 | 95 | $fileA = $files->get('a.txt'); 96 | 97 | static::assertNotNull($fileA); 98 | 99 | static::assertSame('a.txt', $fileA->name); 100 | static::assertSame(File::STATUS_REMOVED, $fileA->status); 101 | 102 | $fileB = $files->get('b.txt'); 103 | 104 | static::assertNotNull($fileB); 105 | 106 | static::assertSame('b2', $fileB->getContent()); 107 | static::assertSame(File::STATUS_ADDED, $fileB->status); 108 | 109 | $fileC = $files->get('c.txt'); 110 | 111 | static::assertNotNull($fileC); 112 | 113 | static::assertSame('c', $fileC->getContent()); 114 | static::assertSame(File::STATUS_ADDED, $fileC->status); 115 | 116 | $fileModified = $files->get('modified.txt'); 117 | static::assertNotNull($fileModified); 118 | static::assertSame(File::STATUS_MODIFIED, $fileModified->status); 119 | } 120 | 121 | public function testGetSingleFile(): void 122 | { 123 | $pr = new LocalPullRequest($this->tmpDir, 'feature2', 'main'); 124 | static::assertSame('', $pr->getFile('a.txt')->getContent()); 125 | } 126 | 127 | public function testGetHeadFile(): void 128 | { 129 | $pr = new LocalPullRequest($this->tmpDir, 'feature2', 'main'); 130 | 131 | $file = $pr->getFileContent('c.txt'); 132 | static::assertSame('c', $file); 133 | 134 | static::expectException(CouldNotGetFileContentException::class); 135 | 136 | $pr->getFileContent('foo.txt'); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Struct/Gitlab/PullRequest.php: -------------------------------------------------------------------------------- 1 | |null 21 | */ 22 | private ?CommitCollection $commits = null; 23 | 24 | /** 25 | * @var FileCollection|null 26 | */ 27 | private ?FileCollection $files = null; 28 | 29 | /** 30 | * @var CommentCollection|null 31 | */ 32 | private ?CommentCollection $comments = null; 33 | 34 | /** 35 | * @var array{'changes': array{'new_path': string, 'diff'?: string, 'new_file': bool, 'deleted_file': bool}[]} 36 | */ 37 | public array $rawFiles = ['changes' => []]; 38 | 39 | public function __construct(private Client $client, private string $latestSha) 40 | { 41 | } 42 | 43 | public function getCommits(): CommitCollection 44 | { 45 | if ($this->commits !== null) { 46 | return $this->commits; 47 | } 48 | 49 | /** @var array{'id': string, 'committed_date': string, 'message': 'string', 'author_name': string, 'author_email': string}[] $list */ 50 | $list = $this->client->mergeRequests()->commits($this->projectIdentifier, (int) $this->id); 51 | $this->rawCommits = $list; 52 | 53 | $collection = new CommitCollection(); 54 | 55 | foreach ($this->rawCommits as $rawGithubCommit) { 56 | $commit = new Commit(); 57 | $commit->sha = $rawGithubCommit['id']; 58 | $commit->createdAt = new \DateTime($rawGithubCommit['committed_date']); 59 | $commit->message = $rawGithubCommit['message']; 60 | $commit->author = $rawGithubCommit['author_name']; 61 | $commit->authorEmail = $rawGithubCommit['author_email']; 62 | $commit->verified = false; 63 | 64 | $collection->add($commit); 65 | } 66 | 67 | return $this->commits = $collection; 68 | } 69 | 70 | public function getFile(string $fileName): File 71 | { 72 | return new GitlabFile($this->client, $this->projectIdentifier, $fileName, $this->latestSha); 73 | } 74 | 75 | public function getFiles(): FileCollection 76 | { 77 | if ($this->files !== null) { 78 | return $this->files; 79 | } 80 | 81 | /** @var array{'changes': array{'new_path': string, 'diff'?: string, 'new_file': bool, 'deleted_file': bool}[]} $list */ 82 | $list = $this->client->mergeRequests()->changes($this->projectIdentifier, (int) $this->id); 83 | $this->rawFiles = $list; 84 | 85 | $collection = new FileCollection(); 86 | 87 | foreach ($this->rawFiles['changes'] as $rawGitlabFile) { 88 | $file = new GitlabFile($this->client, $this->projectIdentifier, $rawGitlabFile['new_path'], $this->latestSha); 89 | $file->name = $rawGitlabFile['new_path']; 90 | $file->status = $this->getState($rawGitlabFile); 91 | $file->additions = 0; 92 | $file->deletions = 0; 93 | $file->changes = $file->additions + $file->deletions; 94 | 95 | if (isset($rawGitlabFile['diff'])) { 96 | $file->patch = $rawGitlabFile['diff']; 97 | } 98 | 99 | $collection->set($file->name, $file); 100 | } 101 | 102 | return $this->files = $collection; 103 | } 104 | 105 | public function getComments(): CommentCollection 106 | { 107 | if ($this->comments !== null) { 108 | return $this->comments; 109 | } 110 | 111 | $this->comments = new CommentCollection(); 112 | 113 | $pager = new ResultPager($this->client); 114 | $list = $pager->fetchAll($this->client->mergeRequests(), 'showNotes', [$this->projectIdentifier, (int) $this->id]); 115 | 116 | foreach ($list as $commentArray) { 117 | if ($commentArray['system']) { 118 | continue; 119 | } 120 | 121 | $comment = new Comment(); 122 | $comment->author = $commentArray['author']['username']; 123 | $comment->body = $commentArray['body']; 124 | $comment->createdAt = new \DateTime($commentArray['created_at']); 125 | $comment->updatedAt = new \DateTime($commentArray['updated_at']); 126 | 127 | $this->comments->add($comment); 128 | } 129 | 130 | return $this->comments; 131 | } 132 | 133 | /** 134 | * @param array{'new_file': bool, 'deleted_file': bool} $rawGitlabFile 135 | */ 136 | private function getState(array $rawGitlabFile): string 137 | { 138 | if ($rawGitlabFile['new_file']) { 139 | return File::STATUS_ADDED; 140 | } 141 | 142 | if ($rawGitlabFile['deleted_file']) { 143 | return File::STATUS_REMOVED; 144 | } 145 | 146 | return File::STATUS_MODIFIED; 147 | } 148 | 149 | public function getFileContent(string $path): string 150 | { 151 | $file = new GitlabFile($this->client, $this->projectIdentifier, $path, $this->latestSha); 152 | 153 | try { 154 | return $file->getContent(); 155 | } catch (\Throwable $e) { 156 | throw new CouldNotGetFileContentException($path, $e); 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /tests/Platform/Github/payloads/files.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "sha": "46d6cdefd6fb365aaa09a8bb1df773689713f76f", 4 | "filename": ".github/checks.php", 5 | "status": "added", 6 | "additions": 10, 7 | "deletions": 0, 8 | "changes": 10, 9 | "blob_url": "https://github.com/FriendsOfShopware/FroshPluginUploader/blob/04911c4a084c06d8edac20cff34c236329175c66/.github/checks.php", 10 | "raw_url": "https://github.com/FriendsOfShopware/FroshPluginUploader/raw/04911c4a084c06d8edac20cff34c236329175c66/.github/checks.php", 11 | "contents_url": "https://api.github.com/repos/FriendsOfShopware/FroshPluginUploader/contents/.github/checks.php?ref=04911c4a084c06d8edac20cff34c236329175c66", 12 | "patch": "@@ -0,0 +1,10 @@\n+\n+ \n+ \n+ \n+ ##NAME##\n+ \n+ \n+ \n+ ##CONTENT##\n+ \n+\n+TABLE;\n+\n+ $itemTpl = <<\n+ ##EMOJI##\n+ ##MSG##\n+ \n+ITEM;\n+\n+\n+ $items = '';\n+\n+ foreach ($entries as $entry) {\n+ $items .= \\str_replace(['##EMOJI##', '##MSG##'], [$emoji, $entry], $itemTpl);\n+ }\n+\n+ return \\str_replace(['##NAME##', '##CONTENT##'], [$name, $items], $tableTpl);\n+}\n+\n+require __DIR__ . '/checks.php';\n+\n+$content = render('Fails', ':no_entry_sign:', $fails) . render('Warnings', ':warning:', $warnings) . render('Notice', ':book:', $notices);\n+\n+echo '::set-output name=BODY::'. str_replace(['%', \"\\n\", '\\r'], ['%25', '%0A', '%0D'], empty($content) ? 'clear' : $content);\n+" 25 | }, 26 | { 27 | "sha": "aab6a78331cb6c9b132fcf452b0470c1a4e67318", 28 | "filename": ".github/workflows/danger.yml", 29 | "status": "removed", 30 | "additions": 0, 31 | "deletions": 16, 32 | "changes": 16, 33 | "blob_url": "https://github.com/FriendsOfShopware/FroshPluginUploader/blob/a75f9fcc16f95511398453aef4be3d6032bc35df/.github/workflows/danger.yml", 34 | "raw_url": "https://github.com/FriendsOfShopware/FroshPluginUploader/raw/a75f9fcc16f95511398453aef4be3d6032bc35df/.github/workflows/danger.yml", 35 | "contents_url": "https://api.github.com/repos/FriendsOfShopware/FroshPluginUploader/contents/.github/workflows/danger.yml?ref=a75f9fcc16f95511398453aef4be3d6032bc35df", 36 | "patch": "@@ -1,16 +0,0 @@\n-name: Danger\n-on:\n- pull_request_target:\n-\n-jobs:\n- build:\n- runs-on: ubuntu-latest\n- steps:\n- - uses: actions/checkout@v1\n-\n- - name: Danger JS\n- run: |\n- npm install danger\n- node node_modules/.bin/danger ci --base master\n- env:\n- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}" 37 | }, 38 | { 39 | "sha": "266fcecaf90d8388eadbb796e9e569ce06c00fdc", 40 | "filename": ".github/workflows/lint.yml", 41 | "status": "added", 42 | "additions": 24, 43 | "deletions": 0, 44 | "changes": 24, 45 | "blob_url": "https://github.com/FriendsOfShopware/FroshPluginUploader/blob/04911c4a084c06d8edac20cff34c236329175c66/.github/workflows/lint.yml", 46 | "raw_url": "https://github.com/FriendsOfShopware/FroshPluginUploader/raw/04911c4a084c06d8edac20cff34c236329175c66/.github/workflows/lint.yml", 47 | "contents_url": "https://api.github.com/repos/FriendsOfShopware/FroshPluginUploader/contents/.github/workflows/lint.yml?ref=04911c4a084c06d8edac20cff34c236329175c66", 48 | "patch": "@@ -0,0 +1,24 @@\n+name: Checking PR\n+on:\n+ pull_request:\n+\n+jobs:\n+ pr:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - name: Clone\n+ uses: actions/checkout@v1\n+\n+ - name: Linting\n+ id: linting\n+ run: php .github/pr_lint.php\n+ env:\n+ GITHUB_CONTEXT: ${{ toJSON(github) }}\n+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n+\n+ - name: Create Comment\n+ uses: mshick/add-pr-comment@v1\n+ with:\n+ message: \"${{ steps.linting.outputs.BODY }}\"\n+ proxy-url: https://kpovtvr2t0.execute-api.eu-central-1.amazonaws.com\n+ repo-token: ${{ secrets.GITHUB_TOKEN }}\n\\ No newline at end of file" 49 | } 50 | ] -------------------------------------------------------------------------------- /tests/Platform/Gitlab/payloads/mr.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 101010928, 3 | "iid": 1, 4 | "project_id": 23869599, 5 | "title": "Update Test", 6 | "description": "Bodyyy", 7 | "state": "opened", 8 | "created_at": "2021-05-21T23:12:46.921Z", 9 | "updated_at": "2021-05-22T08:39:38.878Z", 10 | "merged_by": null, 11 | "merged_at": null, 12 | "closed_by": null, 13 | "closed_at": null, 14 | "target_branch": "master", 15 | "source_branch": "foo", 16 | "user_notes_count": 1, 17 | "upvotes": 0, 18 | "downvotes": 0, 19 | "author": { 20 | "id": 148258, 21 | "name": "Shyim", 22 | "username": "shyim", 23 | "state": "active", 24 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/c98b2f218992c87aee03a2deabe26578?s=80&d=identicon", 25 | "web_url": "https:\/\/gitlab.com\/shyim" 26 | }, 27 | "assignees": [ 28 | { 29 | "id": 148258, 30 | "name": "Shyim", 31 | "username": "shyim", 32 | "state": "active", 33 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/c98b2f218992c87aee03a2deabe26578?s=80&d=identicon", 34 | "web_url": "https:\/\/gitlab.com\/shyim" 35 | } 36 | ], 37 | "assignee": { 38 | "id": 148258, 39 | "name": "Shyim", 40 | "username": "shyim", 41 | "state": "active", 42 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/c98b2f218992c87aee03a2deabe26578?s=80&d=identicon", 43 | "web_url": "https:\/\/gitlab.com\/shyim" 44 | }, 45 | "reviewers": [ 46 | { 47 | "id": 148258, 48 | "name": "dangertestuser", 49 | "username": "dangertestuser", 50 | "state": "active", 51 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/c98b2f218992c87aee03a2deabe26578?s=80&d=identicon", 52 | "web_url": "https:\/\/gitlab.com\/shyim" 53 | }, 54 | { 55 | "id": 148258, 56 | "name": "dangertestuser2", 57 | "username": "dangertestuser2", 58 | "state": "active", 59 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/c98b2f218992c87aee03a2deabe26578?s=80&d=identicon", 60 | "web_url": "https:\/\/gitlab.com\/shyim" 61 | } 62 | ], 63 | "source_project_id": 23869599, 64 | "target_project_id": 23869599, 65 | "labels": [ 66 | "Test" 67 | ], 68 | "work_in_progress": false, 69 | "milestone": null, 70 | "merge_when_pipeline_succeeds": false, 71 | "merge_status": "can_be_merged", 72 | "sha": "2d7f9727fb1a786543df555bb55ad4febeeb2f2f", 73 | "merge_commit_sha": null, 74 | "squash_commit_sha": null, 75 | "discussion_locked": null, 76 | "should_remove_source_branch": null, 77 | "force_remove_source_branch": true, 78 | "reference": "!1", 79 | "references": { 80 | "short": "!1", 81 | "relative": "!1", 82 | "full": "shyim\/test!1" 83 | }, 84 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/merge_requests\/1", 85 | "time_stats": { 86 | "time_estimate": 0, 87 | "total_time_spent": 0, 88 | "human_time_estimate": null, 89 | "human_total_time_spent": null 90 | }, 91 | "squash": false, 92 | "task_completion_status": { 93 | "count": 0, 94 | "completed_count": 0 95 | }, 96 | "has_conflicts": false, 97 | "blocking_discussions_resolved": true, 98 | "approvals_before_merge": null, 99 | "subscribed": true, 100 | "changes_count": "3", 101 | "latest_build_started_at": "2021-05-22T08:39:44.103Z", 102 | "latest_build_finished_at": null, 103 | "first_deployed_to_production_at": null, 104 | "pipeline": { 105 | "id": 307707964, 106 | "project_id": 23869599, 107 | "sha": "2d7f9727fb1a786543df555bb55ad4febeeb2f2f", 108 | "ref": "refs\/merge-requests\/1\/head", 109 | "status": "failed", 110 | "created_at": "2021-05-22T08:39:39.107Z", 111 | "updated_at": "2021-05-22T08:39:58.997Z", 112 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/pipelines\/307707964" 113 | }, 114 | "head_pipeline": { 115 | "id": 307707964, 116 | "project_id": 23869599, 117 | "sha": "2d7f9727fb1a786543df555bb55ad4febeeb2f2f", 118 | "ref": "refs\/merge-requests\/1\/head", 119 | "status": "failed", 120 | "created_at": "2021-05-22T08:39:39.107Z", 121 | "updated_at": "2021-05-22T08:39:58.997Z", 122 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/pipelines\/307707964", 123 | "before_sha": "0000000000000000000000000000000000000000", 124 | "tag": false, 125 | "yaml_errors": null, 126 | "user": { 127 | "id": 148258, 128 | "name": "Shyim", 129 | "username": "shyim", 130 | "state": "active", 131 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/c98b2f218992c87aee03a2deabe26578?s=80&d=identicon", 132 | "web_url": "https:\/\/gitlab.com\/shyim" 133 | }, 134 | "started_at": "2021-05-22T08:39:44.103Z", 135 | "finished_at": "2021-05-22T08:39:58.991Z", 136 | "committed_at": null, 137 | "duration": 17, 138 | "queued_duration": 4, 139 | "coverage": null, 140 | "detailed_status": { 141 | "icon": "status_failed", 142 | "text": "failed", 143 | "label": "failed", 144 | "group": "failed", 145 | "tooltip": "failed", 146 | "has_details": true, 147 | "details_path": "\/shyim\/test\/-\/pipelines\/307707964", 148 | "illustration": null, 149 | "favicon": "https:\/\/gitlab.com\/assets\/ci_favicons\/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" 150 | } 151 | }, 152 | "diff_refs": { 153 | "base_sha": "23e8c0515e85050857fd24cbf3673aa10e4a87cc", 154 | "head_sha": "2d7f9727fb1a786543df555bb55ad4febeeb2f2f", 155 | "start_sha": "23e8c0515e85050857fd24cbf3673aa10e4a87cc" 156 | }, 157 | "merge_error": null, 158 | "first_contribution": false, 159 | "user": { 160 | "can_merge": true 161 | } 162 | } -------------------------------------------------------------------------------- /src/Platform/Gitlab/GitlabCommenter.php: -------------------------------------------------------------------------------- 1 | getRelevantNoteIds($projectIdentifier, $prId); 22 | 23 | if ($config->getUpdateCommentMode() === Config::UPDATE_COMMENT_MODE_REPLACE) { 24 | foreach ($noteIds as $relevantNoteId) { 25 | $this->client->mergeRequests()->removeNote($projectIdentifier, $prId, $relevantNoteId); 26 | } 27 | 28 | /** @var array{'id': string} $note */ 29 | $note = $this->client->mergeRequests()->addNote($projectIdentifier, $prId, $body); 30 | 31 | return $baseUrl . self::NOTE_ANCHOR . $note['id']; 32 | } 33 | 34 | if (\count($noteIds) === 0) { 35 | /** @var array{'id': string} $note */ 36 | $note = $this->client->mergeRequests()->addNote($projectIdentifier, $prId, $body); 37 | 38 | return $baseUrl . self::NOTE_ANCHOR . $note['id']; 39 | } 40 | 41 | $noteId = array_pop($noteIds); 42 | $this->client->mergeRequests()->updateNote($projectIdentifier, $prId, $noteId, $body); 43 | 44 | foreach ($noteIds as $relevantNoteId) { 45 | $this->client->mergeRequests()->removeNote($projectIdentifier, $prId, $relevantNoteId); 46 | } 47 | 48 | return $baseUrl . self::NOTE_ANCHOR . $noteId; 49 | } 50 | 51 | public function postThread(string $projectIdentifier, int $prId, string $body, Config $config, string $baseUrl): string 52 | { 53 | $threadIds = $this->getRelevantThreadIds($projectIdentifier, $prId); 54 | 55 | if ($config->getUpdateCommentMode() === Config::UPDATE_COMMENT_MODE_REPLACE) { 56 | foreach ($threadIds as $threadId) { 57 | $this->client->mergeRequests()->removeDiscussionNote($projectIdentifier, $prId, $threadId['threadId'], $threadId['noteId']); 58 | } 59 | 60 | /** @var array{'notes': array{'id': string}[]} $thread */ 61 | $thread = $this->client->mergeRequests()->addDiscussion($projectIdentifier, $prId, ['body' => $body]); 62 | 63 | return $baseUrl . self::NOTE_ANCHOR . $thread['notes'][0]['id']; 64 | } 65 | 66 | if ($threadIds !== []) { 67 | $foundThread = $threadIds[0]; 68 | 69 | $this->client->mergeRequests()->updateDiscussionNote($projectIdentifier, $prId, $foundThread['threadId'], $foundThread['noteId'], ['body' => $body]); 70 | 71 | if ($foundThread['noteBody'] !== $body) { 72 | $this->client->mergeRequests()->updateDiscussionNote($projectIdentifier, $prId, $foundThread['threadId'], $foundThread['noteId'], ['resolved' => false]); 73 | } 74 | 75 | return $baseUrl . self::NOTE_ANCHOR . $foundThread['noteId']; 76 | } 77 | 78 | /** @var array{'notes': array{'id': string}[]} $thread */ 79 | $thread = $this->client->mergeRequests()->addDiscussion($projectIdentifier, $prId, ['body' => $body]); 80 | 81 | return $baseUrl . self::NOTE_ANCHOR . $thread['notes'][0]['id']; 82 | } 83 | 84 | public function removeNote(string $projectIdentifier, int $prId): void 85 | { 86 | foreach ($this->getRelevantNoteIds($projectIdentifier, $prId) as $relevantNoteId) { 87 | $this->client->mergeRequests()->removeNote($projectIdentifier, $prId, $relevantNoteId); 88 | } 89 | } 90 | 91 | public function removeThread(string $projectIdentifier, int $prId): void 92 | { 93 | foreach ($this->getRelevantThreadIds($projectIdentifier, $prId) as $threadId) { 94 | $this->client->mergeRequests()->removeDiscussionNote($projectIdentifier, $prId, $threadId['threadId'], $threadId['noteId']); 95 | } 96 | } 97 | 98 | /** 99 | * @return int[] 100 | */ 101 | public function getRelevantNoteIds(string $projectIdentifier, int $prId): array 102 | { 103 | $pager = new ResultPager($this->client, 100); 104 | $notes = $pager->fetchAll($this->client->mergeRequests(), 'showNotes', [$projectIdentifier, $prId]); 105 | 106 | $ids = []; 107 | 108 | foreach ($notes as $note) { 109 | if ($note['system']) { 110 | continue; 111 | } 112 | 113 | if (str_contains($note['body'], HTMLRenderer::MARKER)) { 114 | $ids[] = (int) $note['id']; 115 | } 116 | } 117 | 118 | return $ids; 119 | } 120 | 121 | /** 122 | * @return array{'threadId': string, 'noteId': int, 'noteBody': string}[] 123 | */ 124 | private function getRelevantThreadIds(string $projectIdentifier, int $prId): array 125 | { 126 | $pager = new ResultPager($this->client, 100); 127 | $threads = $pager->fetchAll($this->client->mergeRequests(), 'showDiscussions', [$projectIdentifier, $prId]); 128 | 129 | $ids = []; 130 | 131 | foreach ($threads as $thread) { 132 | if (str_contains($thread['notes'][0]['body'], HTMLRenderer::MARKER)) { 133 | $ids[] = [ 134 | 'threadId' => $thread['id'], 135 | 'noteId' => (int) $thread['notes'][0]['id'], 136 | 'noteBody' => $thread['notes'][0]['body'], 137 | ]; 138 | } 139 | } 140 | 141 | return $ids; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /tests/Platform/Github/GithubCommenterTest.php: -------------------------------------------------------------------------------- 1 | 200, 'response_headers' => ['content-type' => 'application/json']]), 33 | ]); 34 | 35 | static::expectException(\RuntimeException::class); 36 | 37 | $commenter = new GithubCommenter($this->createMock(Client::class), $client); 38 | $commenter->comment( 39 | 'test', 40 | 'test', 41 | 'test', 42 | 'test', 43 | (new Config())->useGithubCommentProxy('http://localhost') 44 | ); 45 | } 46 | 47 | public function testCommentUsingProxy(): void 48 | { 49 | $client = new MockHttpClient([ 50 | new MockResponse('{"html_url": "https://test.de"}', ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 51 | ]); 52 | 53 | $commenter = new GithubCommenter($this->createMock(Client::class), $client); 54 | static::assertSame('https://test.de', $commenter->comment( 55 | 'test', 56 | 'test', 57 | 'test', 58 | 'test', 59 | (new Config())->useGithubCommentProxy('http://localhost') 60 | )); 61 | } 62 | 63 | public function testRemoveCommentUsingProxy(): void 64 | { 65 | $client = new MockHttpClient([ 66 | new MockResponse('{}', ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 67 | ]); 68 | 69 | $commenter = new GithubCommenter($this->createMock(Client::class), $client); 70 | $commenter->remove( 71 | 'test', 72 | 'test', 73 | 'test', 74 | (new Config())->useGithubCommentProxy('http://localhost') 75 | ); 76 | 77 | static::assertSame(1, $client->getRequestsCount()); 78 | } 79 | 80 | public function testCommentNew(): void 81 | { 82 | $client = new MockHttpClient([ 83 | new MockResponse((string) file_get_contents(__DIR__ . '/payloads/comments.json'), ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 84 | new MockResponse('{"html_url": "https://test.de"}', ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 85 | ]); 86 | 87 | $commenter = new GithubCommenter(Client::createWithHttpClient(new Psr18Client($client)), $client); 88 | static::assertSame('https://test.de', $commenter->comment( 89 | 'test', 90 | 'test', 91 | 'test', 92 | 'test', 93 | new Config() 94 | )); 95 | } 96 | 97 | public function testCommentUpdate(): void 98 | { 99 | $client = new MockHttpClient([ 100 | new MockResponse((string) file_get_contents(__DIR__ . '/payloads/comments_containg_danger.json'), ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 101 | new MockResponse('{"html_url": "https://test.de"}', ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 102 | new MockResponse('{}', ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 103 | ]); 104 | 105 | $commenter = new GithubCommenter(Client::createWithHttpClient(new Psr18Client($client)), $client); 106 | static::assertSame('https://test.de', $commenter->comment( 107 | 'test', 108 | 'test', 109 | 'test', 110 | 'test', 111 | new Config() 112 | )); 113 | } 114 | 115 | public function testCommentReplace(): void 116 | { 117 | $client = new MockHttpClient([ 118 | new MockResponse((string) file_get_contents(__DIR__ . '/payloads/comments_containg_danger.json'), ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 119 | new MockResponse('{}', ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 120 | new MockResponse('{}', ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 121 | new MockResponse('{"html_url": "https://test.de"}', ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 122 | ]); 123 | 124 | $commenter = new GithubCommenter(Client::createWithHttpClient(new Psr18Client($client)), $client); 125 | static::assertSame('https://test.de', $commenter->comment( 126 | 'test', 127 | 'test', 128 | 'test', 129 | 'test', 130 | (new Config())->useCommentMode(Config::UPDATE_COMMENT_MODE_REPLACE) 131 | )); 132 | } 133 | 134 | public function testRemove(): void 135 | { 136 | $client = new MockHttpClient([ 137 | new MockResponse((string) file_get_contents(__DIR__ . '/payloads/comments_containg_danger.json'), ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 138 | new MockResponse('{}', ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 139 | new MockResponse('{}', ['http_response' => 200, 'response_headers' => ['content-type' => 'application/json']]), 140 | ]); 141 | 142 | $commenter = new GithubCommenter(Client::createWithHttpClient(new Psr18Client($client)), $client); 143 | $commenter->remove( 144 | 'test', 145 | 'test', 146 | 'test', 147 | (new Config())->useCommentMode(Config::UPDATE_COMMENT_MODE_REPLACE) 148 | ); 149 | 150 | static::assertSame(3, $client->getRequestsCount()); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tests/Platform/Gitlab/payloads/files.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 101010928, 3 | "iid": 1, 4 | "project_id": 23869599, 5 | "title": "Update Test", 6 | "description": "Bodyyy", 7 | "state": "opened", 8 | "created_at": "2021-05-21T23:12:46.921Z", 9 | "updated_at": "2021-05-22T08:39:38.878Z", 10 | "merged_by": null, 11 | "merged_at": null, 12 | "closed_by": null, 13 | "closed_at": null, 14 | "target_branch": "master", 15 | "source_branch": "foo", 16 | "user_notes_count": 1, 17 | "upvotes": 0, 18 | "downvotes": 0, 19 | "author": { 20 | "id": 148258, 21 | "name": "Shyim", 22 | "username": "shyim", 23 | "state": "active", 24 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/c98b2f218992c87aee03a2deabe26578?s=80&d=identicon", 25 | "web_url": "https:\/\/gitlab.com\/shyim" 26 | }, 27 | "assignees": [ 28 | { 29 | "id": 148258, 30 | "name": "Shyim", 31 | "username": "shyim", 32 | "state": "active", 33 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/c98b2f218992c87aee03a2deabe26578?s=80&d=identicon", 34 | "web_url": "https:\/\/gitlab.com\/shyim" 35 | } 36 | ], 37 | "assignee": { 38 | "id": 148258, 39 | "name": "Shyim", 40 | "username": "shyim", 41 | "state": "active", 42 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/c98b2f218992c87aee03a2deabe26578?s=80&d=identicon", 43 | "web_url": "https:\/\/gitlab.com\/shyim" 44 | }, 45 | "reviewers": [], 46 | "source_project_id": 23869599, 47 | "target_project_id": 23869599, 48 | "labels": [ 49 | "Test" 50 | ], 51 | "work_in_progress": false, 52 | "milestone": null, 53 | "merge_when_pipeline_succeeds": false, 54 | "merge_status": "can_be_merged", 55 | "sha": "2d7f9727fb1a786543df555bb55ad4febeeb2f2f", 56 | "merge_commit_sha": null, 57 | "squash_commit_sha": null, 58 | "discussion_locked": null, 59 | "should_remove_source_branch": null, 60 | "force_remove_source_branch": true, 61 | "reference": "!1", 62 | "references": { 63 | "short": "!1", 64 | "relative": "!1", 65 | "full": "shyim\/test!1" 66 | }, 67 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/merge_requests\/1", 68 | "time_stats": { 69 | "time_estimate": 0, 70 | "total_time_spent": 0, 71 | "human_time_estimate": null, 72 | "human_total_time_spent": null 73 | }, 74 | "squash": false, 75 | "task_completion_status": { 76 | "count": 0, 77 | "completed_count": 0 78 | }, 79 | "has_conflicts": false, 80 | "blocking_discussions_resolved": true, 81 | "approvals_before_merge": null, 82 | "subscribed": true, 83 | "changes_count": "3", 84 | "latest_build_started_at": "2021-05-22T08:39:44.103Z", 85 | "latest_build_finished_at": null, 86 | "first_deployed_to_production_at": null, 87 | "pipeline": { 88 | "id": 307707964, 89 | "project_id": 23869599, 90 | "sha": "2d7f9727fb1a786543df555bb55ad4febeeb2f2f", 91 | "ref": "refs\/merge-requests\/1\/head", 92 | "status": "failed", 93 | "created_at": "2021-05-22T08:39:39.107Z", 94 | "updated_at": "2021-05-22T08:39:58.997Z", 95 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/pipelines\/307707964" 96 | }, 97 | "head_pipeline": { 98 | "id": 307707964, 99 | "project_id": 23869599, 100 | "sha": "2d7f9727fb1a786543df555bb55ad4febeeb2f2f", 101 | "ref": "refs\/merge-requests\/1\/head", 102 | "status": "failed", 103 | "created_at": "2021-05-22T08:39:39.107Z", 104 | "updated_at": "2021-05-22T08:39:58.997Z", 105 | "web_url": "https:\/\/gitlab.com\/shyim\/test\/-\/pipelines\/307707964", 106 | "before_sha": "0000000000000000000000000000000000000000", 107 | "tag": false, 108 | "yaml_errors": null, 109 | "user": { 110 | "id": 148258, 111 | "name": "Shyim", 112 | "username": "shyim", 113 | "state": "active", 114 | "avatar_url": "https:\/\/secure.gravatar.com\/avatar\/c98b2f218992c87aee03a2deabe26578?s=80&d=identicon", 115 | "web_url": "https:\/\/gitlab.com\/shyim" 116 | }, 117 | "started_at": "2021-05-22T08:39:44.103Z", 118 | "finished_at": "2021-05-22T08:39:58.991Z", 119 | "committed_at": null, 120 | "duration": 17, 121 | "queued_duration": 4, 122 | "coverage": null, 123 | "detailed_status": { 124 | "icon": "status_failed", 125 | "text": "failed", 126 | "label": "failed", 127 | "group": "failed", 128 | "tooltip": "failed", 129 | "has_details": true, 130 | "details_path": "\/shyim\/test\/-\/pipelines\/307707964", 131 | "illustration": null, 132 | "favicon": "https:\/\/gitlab.com\/assets\/ci_favicons\/favicon_status_failed-41304d7f7e3828808b0c26771f0309e55296819a9beea3ea9fbf6689d9857c12.png" 133 | } 134 | }, 135 | "diff_refs": { 136 | "base_sha": "23e8c0515e85050857fd24cbf3673aa10e4a87cc", 137 | "head_sha": "2d7f9727fb1a786543df555bb55ad4febeeb2f2f", 138 | "start_sha": "23e8c0515e85050857fd24cbf3673aa10e4a87cc" 139 | }, 140 | "merge_error": null, 141 | "user": { 142 | "can_merge": true 143 | }, 144 | "changes": [ 145 | { 146 | "old_path": ".danger.php", 147 | "new_path": ".danger.php", 148 | "a_mode": "0", 149 | "b_mode": "100644", 150 | "new_file": true, 151 | "renamed_file": false, 152 | "deleted_file": false, 153 | "diff": "@@ -0,0 +1,9 @@\n+useRule(function (\\Danger\\Context $context) {\n+ $context->failure('Test');\n+ })\n+;\n" 154 | }, 155 | { 156 | "old_path": ".gitlab-ci.yml", 157 | "new_path": ".gitlab-ci.yml", 158 | "a_mode": "0", 159 | "b_mode": "100644", 160 | "new_file": true, 161 | "renamed_file": false, 162 | "deleted_file": false, 163 | "diff": "@@ -0,0 +1,9 @@\n+Danger:\n+ image:\n+ name: ghcr.io\/shyim\/danger-php:latest\n+ entrypoint: [\"\/bin\/sh\", \"-c\"]\n+ rules:\n+ - if: '$CI_PIPELINE_SOURCE == \"merge_request_event\"'\n+ script:\n+ - env\n+ - danger ci\n" 164 | }, 165 | { 166 | "old_path": "Test", 167 | "new_path": "Test", 168 | "a_mode": "100644", 169 | "b_mode": "100644", 170 | "new_file": false, 171 | "renamed_file": false, 172 | "deleted_file": false, 173 | "diff": "@@ -1,2 +1 @@\n-\n-Test\n+Test2\n" 174 | }, 175 | { 176 | "old_path": "Deleted", 177 | "new_path": "Deleted", 178 | "a_mode": "100644", 179 | "b_mode": "100644", 180 | "new_file": false, 181 | "renamed_file": false, 182 | "deleted_file": true, 183 | "diff": "@@ -1,2 +1 @@\n-\n-Test\n+Test2\n" 184 | } 185 | ], 186 | "overflow": false 187 | } --------------------------------------------------------------------------------