├── 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 | ##NAME## |
18 |
19 |
20 |
21 | ##CONTENT##
22 |
23 |
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 | [](https://github.com/shyim/danger-php/blob/main/LICENSE)
12 | [](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 | 
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[](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 | }
--------------------------------------------------------------------------------