├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── mastodon-post.yml │ └── workflows.yml ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE.md ├── README.md ├── bin └── dogit ├── composer.json ├── composer.lock ├── demo ├── demo.gif └── demo.sh ├── phpstan.neon ├── phpstan └── console-application.php ├── phpunit.xml ├── src ├── Commands │ ├── IssueMergeRequest.php │ ├── IssueTimelineCommand.php │ ├── Options │ │ ├── IssueMergeRequestOptions.php │ │ ├── IssueTimelineCommandOptions.php │ │ ├── PatchToBranchOptions.php │ │ ├── ProjectCloneCommandOptions.php │ │ └── ProjectMergeRequestOptions.php │ ├── PatchToBranch.php │ ├── ProjectCloneCommand.php │ ├── ProjectMergeRequest.php │ └── Traits │ │ └── HttpTrait.php ├── DrupalOrg │ ├── DrupalApi.php │ ├── DrupalApiInterface.php │ ├── DrupalOrgObjectCollection.php │ ├── DrupalOrgObjectIterator.php │ ├── DrupalOrgObjectRepository.php │ ├── IssueGraph │ │ ├── DrupalOrgIssueGraph.php │ │ └── Events │ │ │ ├── AssignmentChangeEvent.php │ │ │ ├── CommentEvent.php │ │ │ ├── IssueEvent.php │ │ │ ├── IssueEventInterface.php │ │ │ ├── IssueEventTrait.php │ │ │ ├── MergeRequestCreateEvent.php │ │ │ ├── StatusChangeEvent.php │ │ │ ├── TestResultEvent.php │ │ │ └── VersionChangeEvent.php │ └── Objects │ │ ├── DrupalOrgComment.php │ │ ├── DrupalOrgFile.php │ │ ├── DrupalOrgIssue.php │ │ ├── DrupalOrgObject.php │ │ └── DrupalOrgPatch.php ├── Events │ └── PatchToBranch │ │ ├── AbstractFilterEvent.php │ │ ├── DogitEvent.php │ │ ├── FilterByResponseEvent.php │ │ ├── FilterEvent.php │ │ ├── GitApplyPatchesEvent.php │ │ ├── GitBranchEvent.php │ │ ├── TerminateEvent.php │ │ ├── ValidateLocalRepositoryEvent.php │ │ └── VersionEvent.php ├── Flysystem2Storage.php ├── Git │ ├── CliRunner.php │ ├── CliRunnerInterface.php │ ├── GitOperator.php │ └── GitResolver.php ├── HttplugBrowser.php ├── Listeners │ └── PatchToBranch │ │ ├── Filter │ │ ├── ByConstraintOption.php │ │ ├── ByExcludedCommentOption.php │ │ ├── ByLastOption.php │ │ ├── ByMetadata.php │ │ └── PrimaryTrunk.php │ │ ├── FilterByResponse │ │ └── ByBody.php │ │ ├── GitApplyPatches │ │ └── GitApplyPatches.php │ │ ├── GitBranch │ │ └── GitBranch.php │ │ ├── Terminate │ │ ├── EndMessage.php │ │ └── Statistics.php │ │ ├── ValidateLocalRepository │ │ ├── IsClean.php │ │ └── IsGit.php │ │ └── Version │ │ ├── ByTestResultsEvent.php │ │ └── ByVersionChangeEvent.php ├── ProcessFactory.php └── Utility.php └── tests ├── Commands ├── IssueMergeRequestTest.php ├── IssueTimelineCommand │ └── DogitTimelineTest.php ├── PatchToBranchTest.php ├── ProjectCloneCommandTest.php ├── ProjectMergeRequestTest.php └── Traits │ └── HttpTraitTest.php ├── DogitGuzzleGitlabTestMiddleware.php ├── DogitGuzzleTestMiddleware.php ├── DogitTestBase.php ├── DrupalOrg ├── DrupalOrgApiTest.php ├── DrupalOrgObjectCollectionTest.php ├── DrupalOrgObjectIteratorTest.php ├── DrupalOrgObjectRepositoryTest.php ├── IssueGraph │ ├── DrupalOrgIssueGraphEventsTest.php │ └── DrupalOrgIssueGraphTest.php └── Objects │ ├── DrupalOrgCommentTest.php │ ├── DrupalOrgFileTest.php │ ├── DrupalOrgIssueTest.php │ ├── DrupalOrgObjectTest.php │ └── DrupalOrgPatchTest.php ├── Events └── PatchToBranch │ ├── FilterByResponseEventTest.php │ ├── FilterEventTest.php │ └── VersionEventTest.php ├── Git ├── GitCliRunnerTest.php ├── GitOperatorTest.php └── GitResolverTest.php ├── Listeners └── PatchToBranch │ ├── Filter │ └── ByMetadataTest.php │ ├── FilterByResponse │ └── ByBodyTest.php │ └── GitBranch │ └── GitBranchTest.php ├── ProcessFactoryTest.php ├── TestUtilities.php ├── UtilityTest.php └── fixtures ├── comment-13370001.json ├── comment-13370002.json ├── comment-13370003.json ├── comment-13370004.json ├── comment-13370005.json ├── comment-13370006.json ├── comment-13370007.json ├── comment-13370008.json ├── comment-13370009.json ├── comment-13370010.json ├── composerFiles ├── malformed │ └── composer.json ├── missingName │ └── composer.json ├── notDrupal │ └── composer.json └── valid │ └── composer.json ├── file-22220001.json ├── file-22220002.json ├── file-22220003.json ├── file-22220004.json ├── file-22220999.json ├── gitlab ├── merge_requests-foo_bar_baz.json ├── merge_requests-nomrs.json ├── project-foo_bar_baz.json ├── project-mr.json └── project-nomrs.json ├── issue-11110001.html ├── issue-11110002.html ├── issue-11110002.json ├── issue-11110003.html ├── issue-11110003.json ├── issue.html ├── issue.json └── test.patch /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = LF 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = false 10 | 11 | [phpstan.neon] 12 | indent_style = tab 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "composer" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "thursday" 8 | timezone: "Asia/Singapore" 9 | time: "09:00" 10 | -------------------------------------------------------------------------------- /.github/workflows/mastodon-post.yml: -------------------------------------------------------------------------------- 1 | name: Mastodon Post 2 | 3 | # Credit https://github.com/phpstan/phpstan-strict-rules/blob/1.4.x/.github/workflows/release-toot.yml 4 | 5 | # More triggers 6 | # https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#release 7 | on: 8 | release: 9 | types: [published] 10 | 11 | jobs: 12 | toot: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: cbrgm/mastodon-github-action@v1 16 | if: ${{ !github.event.repository.private }} 17 | with: 18 | # GitHub event payload 19 | # https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#release 20 | message: "New release: ${{ github.event.repository.name }} ${{ github.event.release.tag_name }} ${{ github.event.release.html_url }} #drupal #dogit" 21 | env: 22 | MASTODON_URL: https://drupal.community 23 | MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/workflows.yml: -------------------------------------------------------------------------------- 1 | name: DOGIT 2 | on: [push] 3 | jobs: 4 | code-style: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | php-versions: ['8.1', '8.2', '8.3'] 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: "shivammathur/setup-php@v2" 12 | with: 13 | php-version: ${{ matrix.php-versions }} 14 | env: 15 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | - name: "Install dependencies" 17 | uses: "ramsey/composer-install@v2" 18 | - name: "Run PHP CS Fixer" 19 | run: | 20 | ./vendor/bin/php-cs-fixer --allow-risky=yes --dry-run fix --diff 21 | 22 | static-analysis: 23 | runs-on: ubuntu-latest 24 | strategy: 25 | matrix: 26 | php-versions: ['8.1', '8.2', '8.3'] 27 | steps: 28 | - uses: actions/checkout@v2 29 | - uses: "shivammathur/setup-php@v2" 30 | with: 31 | php-version: ${{ matrix.php-versions }} 32 | env: 33 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | - name: "Install dependencies" 35 | uses: "ramsey/composer-install@v1" 36 | - name: "Run PHPStan" 37 | run: | 38 | ./vendor/bin/phpstan analyse --no-progress 39 | 40 | tests: 41 | runs-on: ubuntu-latest 42 | strategy: 43 | matrix: 44 | php-versions: ['8.1', '8.2', '8.3'] 45 | dependency-versions: ['highest', 'lowest'] 46 | steps: 47 | - uses: actions/checkout@v2 48 | - uses: "shivammathur/setup-php@v2" 49 | with: 50 | php-version: ${{ matrix.php-versions }} 51 | env: 52 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | - name: Setup problem matchers for PHP 54 | run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" 55 | - name: Setup problem matchers for PHPUnit 56 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 57 | - name: "Install dependencies" 58 | uses: "ramsey/composer-install@v2" 59 | with: 60 | dependency-versions: ${{ matrix.dependency-versions }} 61 | - name: "Prepare for tests" 62 | run: "mkdir -p build/logs" 63 | - name: "Run tests" 64 | run: | 65 | ./vendor/bin/phpunit 66 | - name: "Publish coverage report to Codecov" 67 | uses: "codecov/codecov-action@v1" 68 | with: 69 | token: ${{ secrets.CODECOV_TOKEN }} 70 | files: build/logs/clover.xml 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .phpunit.cache 3 | .phpunit.result.cache 4 | .php-cs-fixer.cache 5 | composer.lock 6 | vendor/ 7 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | in([ 5 | 'bin', 6 | 'src', 7 | 'tests', 8 | ]) 9 | ->exclude([ 10 | 'cache', 11 | 'vendor', 12 | ]); 13 | 14 | return (new PhpCsFixer\Config('DOGIT')) 15 | ->setFinder($finder) 16 | ->setRules([ 17 | '@Symfony' => true, 18 | 'array_syntax' => ['syntax' => 'short'], 19 | 'concat_space' => ['spacing' => 'one'], 20 | 'global_namespace_import' => false, 21 | 'fully_qualified_strict_types' => true, 22 | 'declare_strict_types' => true, 23 | 'phpdoc_summary' => true, 24 | 'phpdoc_align' => false, 25 | 'phpdoc_no_useless_inheritdoc' => true, 26 | 'strict_param' => true, 27 | 'ordered_imports' => true, 28 | 'function_declaration' => [ 29 | 'closure_function_spacing' => 'one', 30 | ], 31 | 'increment_style' => false, 32 | ]); 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Modified BSD License 2 | ==================== 3 | 4 | _Copyright © `2021`, `Daniel Phin`_ 5 | _All rights reserved._ 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright 11 | notice, this list of conditions and the following disclaimer. 12 | 2. Redistributions in binary form must reproduce the above copyright 13 | notice, this list of conditions and the following disclaimer in the 14 | documentation and/or other materials provided with the distribution. 15 | 3. Neither the name of the `` nor the 16 | names of its contributors may be used to endorse or promote products 17 | derived from this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL `` BE LIABLE FOR ANY 23 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 26 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # doGit 2 | 3 | [![Latest Stable Version](http://poser.pugx.org/dpi/dogit/v)](https://packagist.org/packages/dpi/dogit) 4 | [![Total Downloads](http://poser.pugx.org/dpi/dogit/downloads)](https://packagist.org/packages/dpi/dogit) 5 | [![Codecov](https://img.shields.io/codecov/c/github/dpi/dogit)][code-coverage] 6 | [![GitHub branch checks state](https://img.shields.io/github/checks-status/dpi/dogit/1.x)][ci] 7 | [![License](http://poser.pugx.org/dpi/dogit/license)](https://packagist.org/packages/dpi/dogit) 8 | 9 | _[Drupal.org](https://www.drupal.org/) + Git CLI application._ 10 | 11 | 🐘 Follow on Mastodon: [@dogit@drupal.community][mastodon-profile] 12 | 13 | [doGit](https://dogit.dev) assists in making the transition to merge requests, and general Git operations, easier for [Drupal](https://www.drupal.org/) developers. 14 | 15 | [![Animated Demo Image](demo/demo.gif)][asciicinema-demo] 16 | 17 | doGit is typically required globally with [Composer](https://getcomposer.org/). 18 | 19 | ```shell 20 | composer global require dpi/dogit 21 | ``` 22 | 23 | Various commands are included: 24 | 25 | - [**Convert** a Drupal.org issue with existing patches to a Git branch][wiki-PatchToBranch], ready to be pushed as a new merge request, as `dogit convert ISSUE-ID`. 26 | - [Interactively **clone** or **checkout** a merge request of a project][wiki-ProjectMergeRequest], as `dogit project:mr PROJECT`. 27 | - [Interactively **clone** or **checkout** a merge request of an issue][wiki-IssueMergeRequest], as `dogit issue:mr ISSUE-ID`. 28 | - [**Clone** a project][wiki-ProjectCloneCommand], as `dogit project:clone PROJECT`. 29 | - [**Show** an issue timeline][wiki-IssueTimelineCommand], as `dogit issue:timeline ISSUE-ID`. 30 | 31 | Start with the [wiki](https://github.com/dpi/dogit/wiki), or run `dogit list` or `dogit COMMAND --help` 32 | 33 | _Drupal is a registered trademark of Dries Buytaert._ 34 | 35 | [ci]: https://github.com/dpi/dogit/actions 36 | [code-coverage]: https://app.codecov.io/gh/dpi/dogit 37 | [asciicinema-demo]: https://asciinema.org/a/431178 38 | [mastodon-profile]: https://drupal.community/@dogit 39 | [wiki-PatchToBranch]: https://github.com/dpi/dogit/wiki/Issue-Patches-to-Git-Branch-Command 40 | [wiki-ProjectMergeRequest]: https://github.com/dpi/dogit/wiki/Project-Merge-Request-Command 41 | [wiki-IssueMergeRequest]: https://github.com/dpi/dogit/wiki/Issue-Merge-Request-Command 42 | [wiki-ProjectCloneCommand]: https://github.com/dpi/dogit/wiki/Clone-Project-Command 43 | [wiki-IssueTimelineCommand]: https://github.com/dpi/dogit/wiki/Show-Issue-Timeline-Command 44 | -------------------------------------------------------------------------------- /bin/dogit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add(new ProjectMergeRequest()); 18 | $application->add(new PatchToBranch()); 19 | $application->add(new IssueMergeRequest()); 20 | $application->add(new IssueTimelineCommand()); 21 | $application->add(new ProjectCloneCommand()); 22 | 23 | $application->run(); 24 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dpi/dogit", 3 | "type": "application", 4 | "require": { 5 | "php": "^8.1", 6 | "ext-dom": "*", 7 | "ext-json": "*", 8 | "composer/semver": "^3.4.0", 9 | "czproject/git-php": "^4.2.0", 10 | "guzzlehttp/guzzle": "^7.8.1", 11 | "kevinrob/guzzle-cache-middleware": "^5.1", 12 | "league/flysystem": "^3.28.0", 13 | "m4tthumphrey/php-gitlab-api": "^11.14", 14 | "nyholm/psr7": "^1.8.1", 15 | "php-http/client-common": "^2.7.1", 16 | "php-http/curl-client": "^2.3.2", 17 | "php-http/discovery": "^1.19.4", 18 | "php-http/guzzle7-adapter": "^1.0", 19 | "php-http/httplug-bundle": "^1.34.0", 20 | "php-http/message": "^1.16.1", 21 | "psr/http-client": "^1.0.3", 22 | "psr/log": "^3.0", 23 | "symfony/browser-kit": "^6.4.8", 24 | "symfony/console": "^6.4.9", 25 | "symfony/css-selector": "^6.4.8", 26 | "symfony/dom-crawler": "^6.4.8", 27 | "symfony/event-dispatcher": "^6.4.8", 28 | "symfony/filesystem": "^6.4.9", 29 | "symfony/finder": "^6.4.8", 30 | "symfony/process": "^6.4.8", 31 | "symfony/options-resolver": "^6.4.8" 32 | }, 33 | "require-dev": { 34 | "friendsofphp/php-cs-fixer": "^3.59.3", 35 | "jangregor/phpstan-prophecy": "^1.0.2", 36 | "mikey179/vfsstream": "^1.6.11", 37 | "mockery/mockery": "^1.6.12", 38 | "phpspec/prophecy": "^1.19", 39 | "phpspec/prophecy-phpunit": "^2.2.0", 40 | "phpstan/extension-installer": "^1.4.1", 41 | "phpstan/phpstan": "1.11.7", 42 | "phpstan/phpstan-mockery": "^1.1.2", 43 | "phpstan/phpstan-phpunit": "^1.3.16", 44 | "phpstan/phpstan-strict-rules": "^1.5.1", 45 | "phpstan/phpstan-symfony": "^1.3.3", 46 | "phpunit/phpunit": "^10.5.24" 47 | }, 48 | "config": { 49 | "sort-packages": true, 50 | "allow-plugins": { 51 | "phpstan/extension-installer": true, 52 | "php-http/discovery": true 53 | }, 54 | "platform": { 55 | "php": "8.1.99999999" 56 | } 57 | }, 58 | "authors": [ 59 | { 60 | "name": "dpi", 61 | "email": "pro@danielph.in" 62 | } 63 | ], 64 | "autoload": { 65 | "psr-4": { 66 | "dogit\\": "src/", 67 | "dogit\\tests\\": "tests/" 68 | } 69 | }, 70 | "bin": [ 71 | "bin/dogit" 72 | ], 73 | "license": "BSD-3-Clause" 74 | } 75 | -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dpi/dogit/901c0f5f007adeb500fac190af0e3711f37a6b58/demo/demo.gif -------------------------------------------------------------------------------- /demo/demo.sh: -------------------------------------------------------------------------------- 1 | # dogit project:clone PROJECT 2 | # Checkout a project 3 | dogit project:clone scheduled_transitions 4 | cd scheduled_transitions/ 5 | git status 6 | ls 7 | 8 | # dogit project:mr PROJECT 9 | # Checkout an existing MR, by project name 10 | dogit project:mr 11 | >8 12 | 13 | # dogit issue:mr ISSUE-ID 14 | # Checkout an existing MR, by issue ID 15 | dogit issue:mr 3073549 16 | > 9 17 | 18 | # dogit convert ISSUE-ID 19 | # Convert an existing issue comprised of patches to a Git branch 20 | dogit convert 3082728 . 21 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - ./bin 5 | - ./src 6 | - ./tests 7 | symfony: 8 | console_application_loader: phpstan/console-application.php 9 | ignoreErrors: 10 | - '#Dynamic call to static method PHPUnit\\Framework\\.*#' 11 | -------------------------------------------------------------------------------- /phpstan/console-application.php: -------------------------------------------------------------------------------- 1 | add(new ProjectMergeRequest()); 17 | $application->add(new PatchToBranch()); 18 | $application->add(new IssueMergeRequest()); 19 | $application->add(new IssueTimelineCommand()); 20 | $application->add(new ProjectCloneCommand()); 21 | 22 | return $application; 23 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | 15 | 16 | src/ 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Commands/IssueTimelineCommand.php: -------------------------------------------------------------------------------- 1 | addArgument(IssueTimelineCommandOptions::ARGUMENT_ISSUE_ID, InputArgument::REQUIRED) 33 | ->addOption(IssueTimelineCommandOptions::OPTION_NO_COMMENTS, 'c', InputOption::VALUE_NONE) 34 | ->addOption(IssueTimelineCommandOptions::OPTION_NO_EVENTS, 'e', InputOption::VALUE_NONE) 35 | ->setAliases(['itl']); 36 | } 37 | 38 | protected function execute(InputInterface $input, OutputInterface $output): int 39 | { 40 | $io = new SymfonyStyle($input, $output); 41 | $logger = new ConsoleLogger($io); 42 | 43 | $options = IssueTimelineCommandOptions::fromInput($input); 44 | 45 | [$httpFactory, $httpAsyncClient] = $this->http($logger); 46 | 47 | $repository = new DrupalOrgObjectRepository(); 48 | $api = new DrupalApi($httpFactory, $httpAsyncClient, $repository); 49 | $objectIterator = new DrupalOrgObjectIterator($api, $logger); 50 | $issue = $api->getIssue($options->nid); 51 | 52 | try { 53 | $events = iterator_to_array((new DrupalOrgIssueGraph( 54 | $httpFactory, 55 | $httpAsyncClient, 56 | $repository, 57 | $issue->url(), 58 | ))->graph()); 59 | } catch (\Exception $e) { 60 | $io->error('Failed to build issue graph' . $e->getMessage()); 61 | 62 | return self::FAILURE; 63 | } 64 | 65 | uasort($events, fn (IssueEventInterface $eventA, IssueEventInterface $eventB): int => $eventA->getComment()->id() <=> $eventB->getComment()->id()); 66 | 67 | $events = array_filter( 68 | $events, 69 | fn (IssueEventInterface $event): bool => $event instanceof CommentEvent 70 | ? !$options->noComments 71 | : !$options->noEvents 72 | ); 73 | 74 | // Group events by comment. 75 | $comments = []; 76 | $objectIterator->unstubComments(iterator_to_array(Utility::getCommentsFromEvents($events))); 77 | foreach ($events as $event) { 78 | $comments[$event->getComment()->id()][] = $event; 79 | } 80 | 81 | foreach ($comments as $commentEvents) { 82 | $firstComment = reset($commentEvents)->getComment(); 83 | $io->title(sprintf('Comment #%s (%d) at %s', 84 | $firstComment->url(), 85 | $firstComment->id(), 86 | $firstComment->getSequence(), 87 | $firstComment->getCreated()->format('r'), 88 | )); 89 | foreach ($commentEvents as $commentEvent) { 90 | $io->text((string) $commentEvent); 91 | } 92 | } 93 | 94 | return Command::SUCCESS; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Commands/Options/IssueMergeRequestOptions.php: -------------------------------------------------------------------------------- 1 | cookies = $input->getOption(static::OPTION_COOKIE); 32 | $directory = (string) $input->getArgument(static::ARGUMENT_DIRECTORY); 33 | $cwd = \getcwd(); 34 | $instance->directory = ('.' === $directory && is_string($cwd)) ? $cwd : $directory; 35 | $instance->isHttp = $input->getOption(static::OPTION_HTTP); 36 | 37 | $nid = $input->getArgument(static::ARGUMENT_ISSUE_ID); 38 | $instance->nid = (1 === preg_match('/^\d{1,10}$/m', $nid)) ? (int) $nid : throw new \UnexpectedValueException('Issue ID is not valid'); 39 | $instance->noHttpCache = (bool) $input->getOption(static::OPTION_NO_CACHE); 40 | $instance->single = (bool) $input->getOption(static::OPTION_SINGLE); 41 | 42 | return $instance; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Commands/Options/IssueTimelineCommandOptions.php: -------------------------------------------------------------------------------- 1 | getArgument(static::ARGUMENT_ISSUE_ID); 25 | $instance->nid = (1 === preg_match('/^\d{1,10}$/m', $nid)) ? (int) $nid : throw new \UnexpectedValueException('Issue ID is not valid'); 26 | $instance->noComments = (bool) $input->getOption(static::OPTION_NO_COMMENTS); 27 | $instance->noEvents = (bool) $input->getOption(static::OPTION_NO_EVENTS); 28 | 29 | return $instance; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Commands/Options/PatchToBranchOptions.php: -------------------------------------------------------------------------------- 1 | branchName = $input->getOption(static::OPTION_BRANCH); 51 | $instance->branchDeleteExisting = (bool) $input->getOption(static::OPTION_DELETE_EXISTING_BRANCH); 52 | $instance->cookies = $input->getOption(static::OPTION_COOKIE); 53 | $instance->excludeComments = $input->getOption(static::OPTION_EXCLUDE); 54 | $instance->gitDirectory = (string) ($input->getArgument(static::ARGUMENT_WORKING_DIRECTORY) ?? getcwd()); 55 | 56 | $nid = $input->getArgument(static::ARGUMENT_ISSUE_ID); 57 | 58 | $instance->nid = (1 === preg_match('/^\d{1,10}$/m', $nid)) ? (int) $nid : throw new \UnexpectedValueException('Issue ID is not valid'); 59 | $instance->noHttpCache = (bool) $input->getOption(static::OPTION_NO_CACHE); 60 | $instance->onlyLastPatch = $input->getOption(static::OPTION_LAST); 61 | 62 | $patchLevel = $input->getOption(static::OPTION_PATCH_LEVEL); 63 | $instance->patchLevel = (1 === preg_match('/^\d$/m', (string) $patchLevel)) ? (int) $patchLevel : throw new \UnexpectedValueException('Patch level is not valid'); 64 | $instance->resetUnclean = (bool) $input->getOption(static::OPTION_RESET); 65 | 66 | // Check is a proper Composer constraint. e.g 8.8.x is not accepted: 67 | $constraint = (string) $input->getArgument(static::ARGUMENT_VERSION_CONSTRAINTS); 68 | if (strlen($constraint) > 0) { 69 | try { 70 | (new VersionParser())->parseConstraints($constraint); 71 | } catch (\UnexpectedValueException $e) { 72 | throw $e; 73 | } catch (\Exception $e) { 74 | throw new \UnexpectedValueException('Failed to parse constraint.', 0, $e); 75 | } 76 | } 77 | $instance->versionConstraints = $constraint; 78 | 79 | return $instance; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Commands/Options/ProjectCloneCommandOptions.php: -------------------------------------------------------------------------------- 1 | directory = $input->getArgument(static::ARGUMENT_DIRECTORY); 26 | $instance->project = $input->getArgument(static::ARGUMENT_PROJECT); 27 | $instance->branch = $input->getOption(static::OPTION_BRANCH) ?? null; 28 | $instance->isHttp = $input->getOption(static::OPTION_HTTP); 29 | 30 | return $instance; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Commands/Options/ProjectMergeRequestOptions.php: -------------------------------------------------------------------------------- 1 | branchName = $input->getOption(static::OPTION_BRANCH); 34 | $directory = (string) $input->getArgument(static::ARGUMENT_DIRECTORY); 35 | $cwd = \getcwd(); 36 | $instance->directory = ('.' === $directory && is_string($cwd)) ? $cwd : $directory; 37 | $instance->project = $input->getArgument(static::ARGUMENT_PROJECT); 38 | $instance->isHttp = $input->getOption(static::OPTION_HTTP); 39 | $instance->includeAll = (bool) $input->getOption(static::OPTION_ALL); 40 | $instance->onlyClosed = (bool) $input->getOption(static::OPTION_ONLY_CLOSED); 41 | $instance->onlyMerged = (bool) $input->getOption(static::OPTION_ONLY_MERGED); 42 | $instance->noHttpCache = (bool) $input->getOption(static::OPTION_NO_CACHE); 43 | 44 | return $instance; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Commands/ProjectCloneCommand.php: -------------------------------------------------------------------------------- 1 | git = $this->git($runner ?? new \dogit\Git\CliRunner()); 34 | $this->finder = $finder ?? new Finder(); 35 | } 36 | 37 | protected function configure(): void 38 | { 39 | $this 40 | ->addArgument(ProjectCloneCommandOptions::ARGUMENT_PROJECT, InputArgument::REQUIRED) 41 | ->addArgument(ProjectCloneCommandOptions::ARGUMENT_DIRECTORY, InputArgument::OPTIONAL) 42 | ->addOption(ProjectCloneCommandOptions::OPTION_HTTP, null, InputOption::VALUE_NONE, 'Use HTTP instead of SSH.') 43 | ->addOption(ProjectCloneCommandOptions::OPTION_BRANCH, 'b', InputOption::VALUE_REQUIRED, 'Clone a specific branch. Omit to clone default branch.') 44 | ->setAliases(['pc']); 45 | } 46 | 47 | protected function execute(InputInterface $input, OutputInterface $output): int 48 | { 49 | $io = new SymfonyStyle($input, $output); 50 | $options = ProjectCloneCommandOptions::fromInput($input); 51 | 52 | $url = sprintf($options->isHttp ? 'https://git.drupalcode.org/project/%s.git' : 'git@git.drupal.org:project/%s.git', $options->project); 53 | $params = []; 54 | if (null !== $options->branch && strlen($options->branch) > 0) { 55 | $params['-b'] = $options->branch; 56 | } 57 | 58 | // When directory isnt provided then use a directory with the same name as the project, if the directory 59 | // doesn't exist. 60 | $directory = $options->directory; 61 | if (null === $directory) { 62 | $directory = $options->project; 63 | } 64 | 65 | try { 66 | $this->git->cloneRepository( 67 | $url, 68 | $directory, 69 | $params, 70 | ); 71 | } catch (GitException $e) { 72 | $io->error(sprintf('Unable to clone repository: %s', $e->getMessage())); 73 | 74 | return static::FAILURE; 75 | } 76 | 77 | $io->success('Done'); 78 | 79 | return static::SUCCESS; 80 | } 81 | 82 | protected function git(IRunner $runner): Git 83 | { 84 | return new Git($runner); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Commands/Traits/HttpTrait.php: -------------------------------------------------------------------------------- 1 | handlerStack()->push(Middleware::log( 35 | $logger, 36 | new MessageFormatter('{method} {code} {uri} [Cache {res_header_' . strtolower(CacheMiddleware::HEADER_CACHE_INFO) . '}]'), 37 | LogLevel::DEBUG, 38 | ), 'logger'); 39 | 40 | if (!$noHttpCache) { 41 | $dir = '/tmp/dogit/http_cache/'; 42 | $flySystem = new Flysystem2Storage(new LocalFilesystemAdapter($dir)); 43 | $cacheStrategy = new PrivateCacheStrategy($flySystem); 44 | $this->handlerStack()->push(new CacheMiddleware($cacheStrategy), 'cache'); 45 | } 46 | 47 | $cookieArray = array_map(fn (string $cookie): SetCookie => SetCookie::fromString($cookie), $cookies); 48 | $cookieJar = new CookieJar(false, $cookieArray); 49 | 50 | $httpFactory = Psr17FactoryDiscovery::findRequestFactory(); 51 | $httpAsyncClient = new Client(new GuzzleClient([ 52 | 'handler' => $this->handlerStack(), 53 | RequestOptions::COOKIES => $cookieJar, 54 | RequestOptions::HEADERS => [ 55 | 'User-Agent' => 'Dogit www.dogit.dev', 56 | ], 57 | ])); 58 | 59 | return [$httpFactory, $httpAsyncClient]; 60 | } 61 | 62 | public function handlerStack(): HandlerStack 63 | { 64 | return $this->handlerStack = ($this->handlerStack ?? HandlerStack::create()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/DrupalOrg/DrupalApi.php: -------------------------------------------------------------------------------- 1 | httpClient->sendAsyncRequest( 34 | $this->httpFactory->createRequest('GET', sprintf(static::ENDPOINT_NODE, $nid)) 35 | )->wait(); 36 | $issue = DrupalOrgIssue::fromResponse($response, $this->repository); 37 | 38 | return $this->repository->share($issue); 39 | } 40 | 41 | public function getCommentAsync(DrupalOrgComment $comment): Promise 42 | { 43 | return $this->httpClient->sendAsyncRequest( 44 | $this->httpFactory->createRequest('GET', sprintf(static::ENDPOINT_COMMENT, $comment->id())) 45 | ); 46 | } 47 | 48 | public function getFileAsync(DrupalOrgFile $file): Promise 49 | { 50 | return $this->httpClient->sendAsyncRequest( 51 | $this->httpFactory->createRequest('GET', sprintf(static::ENDPOINT_FILE, $file->id())) 52 | ); 53 | } 54 | 55 | public function getPatchFileAsync(DrupalOrgPatch $patch): Promise 56 | { 57 | return $this->httpClient->sendAsyncRequest( 58 | $this->httpFactory->createRequest('GET', $patch->getUrl()) 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/DrupalOrg/DrupalApiInterface.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private \WeakMap $collection; 18 | 19 | public function __construct() 20 | { 21 | $this->collection = new \WeakMap(); 22 | } 23 | 24 | /** 25 | * @param T $object 26 | * 27 | * @return T 28 | * The returned object type will be the same type as the one passed in. 29 | * The object will either be a reference to the same object if it doesn't 30 | * exist in the repo, or an existing object from the repository will be 31 | * returned instead. 32 | */ 33 | public function share(DrupalOrgObject $object): DrupalOrgObject 34 | { 35 | $objectKey = $object->id(); 36 | foreach ($this->collection as $repoObject => $repoKey) { 37 | if ($objectKey === $repoKey) { 38 | return $repoObject; 39 | } 40 | } 41 | 42 | $this->collection[$object] = $objectKey; 43 | 44 | return $object; 45 | } 46 | 47 | /** 48 | * @return \Generator 49 | */ 50 | public function all(): \Generator 51 | { 52 | foreach ($this->collection as $repoObject => $repoKey) { 53 | yield $repoObject; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/DrupalOrg/DrupalOrgObjectIterator.php: -------------------------------------------------------------------------------- 1 | filterStubbed($comments); 35 | if (0 === count($requestComments)) { 36 | return $comments; 37 | } 38 | 39 | $promises = array_map( 40 | fn (DrupalOrgComment $comment): Promise => $this->api->getCommentAsync($comment), 41 | $requestComments, 42 | ); 43 | $this->logger->debug('Batched comment requests for {ids}', [ 44 | 'ids' => implode(', ', array_map( 45 | fn (DrupalOrgComment $comment): int => $comment->id(), 46 | $requestComments 47 | )), 48 | ]); 49 | 50 | $responses = $this->unwrap($promises); 51 | foreach ($responses as $key => $response) { 52 | $requestComments[$key]->importResponse($response); 53 | } 54 | 55 | return $comments; 56 | } 57 | 58 | /** 59 | * @param \dogit\DrupalOrg\Objects\DrupalOrgFile[] $files 60 | * 61 | * @return \dogit\DrupalOrg\Objects\DrupalOrgFile[] 62 | * It's not necessary to use return value, can re-use $files. Though return 63 | * value will have its objects de-duplicated. 64 | */ 65 | public function unstubFiles(array $files): array 66 | { 67 | $files = Utility::deduplicateDrupalOrgObjects($files); 68 | $requestFiles = $this->filterStubbed($files); 69 | if (0 === count($requestFiles)) { 70 | return $files; 71 | } 72 | 73 | $promises = array_map( 74 | fn (DrupalOrgFile $file): Promise => $this->api->getFileAsync($file), 75 | $requestFiles, 76 | ); 77 | $this->logger->debug('Batched file requests for {ids}', [ 78 | 'ids' => implode(', ', array_map( 79 | fn (DrupalOrgFile $file): int => $file->id(), 80 | $requestFiles, 81 | )), 82 | ]); 83 | $responses = $this->unwrap($promises); 84 | foreach ($responses as $key => $response) { 85 | $requestFiles[$key]->importResponse($response); 86 | } 87 | 88 | return $files; 89 | } 90 | 91 | /** 92 | * @param \dogit\DrupalOrg\Objects\DrupalOrgPatch[] $patches 93 | * Patches must not be stubs 94 | * 95 | * @return \dogit\DrupalOrg\Objects\DrupalOrgPatch[] 96 | * It's not necessary to use return value, can re-use $patches. Though return 97 | * value will have its objects de-duplicated. 98 | * 99 | * @throws \Exception 100 | */ 101 | public function downloadPatchFiles(array $patches): array 102 | { 103 | /** @var \dogit\DrupalOrg\Objects\DrupalOrgPatch[] $requestPatches */ 104 | $requestPatches = Utility::deduplicateDrupalOrgObjects($patches); 105 | if (0 === count($requestPatches)) { 106 | return $patches; 107 | } 108 | 109 | $promises = array_map( 110 | fn (DrupalOrgPatch $patch): Promise => $this->api->getPatchFileAsync($patch), 111 | $requestPatches, 112 | ); 113 | $this->logger->debug('Batched patch requests for {ids}', [ 114 | 'ids' => implode(', ', array_map( 115 | fn (DrupalOrgPatch $patch): int => $patch->id(), 116 | $requestPatches, 117 | )), 118 | ]); 119 | $responses = $this->unwrap($promises); 120 | foreach ($responses as $key => $response) { 121 | $requestPatches[$key]->setContents((string) $response->getBody()); 122 | } 123 | 124 | return $patches; 125 | } 126 | 127 | /** 128 | * @param \Http\Promise\Promise[]|iterable $promises 129 | * 130 | * @return \GuzzleHttp\Psr7\Response[] 131 | * 132 | * @throws \GuzzleHttp\Promise\RejectionException 133 | */ 134 | protected function unwrap(iterable $promises) 135 | { 136 | $responses = []; 137 | (new EachPromise($promises, [ 138 | 'concurrency' => 4, 139 | 'fulfilled' => function ($response, $key) use (&$responses) { 140 | $responses[$key] = $response; 141 | }, 142 | 'rejected' => function (HttpException $reason, int $key, GuzzlePromise $aggregate) { 143 | $aggregate->reject('A request failed: ' . $reason->getMessage()); 144 | }, 145 | ]))->promise()->wait(); 146 | 147 | return $responses; 148 | } 149 | 150 | /** 151 | * Dont try to unstub already unstubbed. 152 | * 153 | * @template T of \dogit\DrupalOrg\Objects\DrupalOrgObject 154 | * 155 | * @param T[] $objects 156 | * 157 | * @return T[] 158 | */ 159 | protected function filterStubbed(array $objects): array 160 | { 161 | return array_filter($objects, fn (DrupalOrgObject $object): bool => $object->isStub()); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/DrupalOrg/DrupalOrgObjectRepository.php: -------------------------------------------------------------------------------- 1 | [] 13 | */ 14 | private array $collections = []; 15 | 16 | /** 17 | * @template T of \dogit\DrupalOrg\Objects\DrupalOrgObject 18 | * 19 | * @param T $object 20 | * 21 | * @return T 22 | * The returned object type will be the same type as the one passed in. 23 | * The object will either be a reference to the same object if it doesn't 24 | * exist in the repo, or an existing object from the repository will be 25 | * returned instead. 26 | */ 27 | public function share(DrupalOrgObject $object): DrupalOrgObject 28 | { 29 | /** @var \dogit\DrupalOrg\DrupalOrgObjectCollection $collection */ 30 | $collection = $this->collections[$object::class] ?? ( 31 | $this->collections[$object::class] = new DrupalOrgObjectCollection() 32 | ); 33 | 34 | $object = $collection->share($object); 35 | $object->setRepository($this); 36 | 37 | return $object; 38 | } 39 | 40 | /** 41 | * Get all objects in all collections. 42 | * 43 | * @return \Generator 44 | * Yields instances of \dogit\DrupalOrg\Objects\DrupalOrgObject 45 | */ 46 | public function all(): \Generator 47 | { 48 | foreach ($this->collections as $collection) { 49 | yield from $collection->all(); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/DrupalOrg/IssueGraph/Events/AssignmentChangeEvent.php: -------------------------------------------------------------------------------- 1 | from = trim($from, " \t\n\r\0\x0B»"); 26 | $this->to = trim($to, " \t\n\r\0\x0B»"); 27 | } 28 | 29 | public function from(): string 30 | { 31 | return $this->from; 32 | } 33 | 34 | public function to(): string 35 | { 36 | return $this->to; 37 | } 38 | 39 | public function __toString(): string 40 | { 41 | return sprintf('Assignment change from %s to %s', $this->from(), $this->to()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/DrupalOrg/IssueGraph/Events/CommentEvent.php: -------------------------------------------------------------------------------- 1 | comment->isBot() ? '🤖' : '👤', 22 | $this->comment->getAuthorName(), 23 | $this->comment->getCreated()->format('r'), 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/DrupalOrg/IssueGraph/Events/IssueEvent.php: -------------------------------------------------------------------------------- 1 | new StatusChangeEvent($comment, ...$data), 27 | 'Version' => new VersionChangeEvent($comment, ...$data), 28 | 'Assigned' => new AssignmentChangeEvent($comment, ...$data), 29 | default => new static($comment, $data), 30 | }; 31 | } 32 | 33 | /** 34 | * @param \dogit\DrupalOrg\IssueGraph\Events\IssueEventInterface[] $events 35 | * 36 | * @return \dogit\DrupalOrg\IssueGraph\Events\MergeRequestCreateEvent[] 37 | */ 38 | public static function filterMergeRequestCreateEvents(array $events): array 39 | { 40 | return array_filter( 41 | $events, 42 | fn (IssueEventInterface $event): bool => $event instanceof MergeRequestCreateEvent 43 | ); 44 | } 45 | 46 | /** 47 | * @param \dogit\DrupalOrg\IssueGraph\Events\IssueEventInterface[] $events 48 | * 49 | * @return \dogit\DrupalOrg\IssueGraph\Events\VersionChangeEvent[] 50 | */ 51 | public static function filterVersionChangeEvents(array $events): array 52 | { 53 | return array_filter($events, fn (IssueEventInterface $event): bool => $event instanceof VersionChangeEvent); 54 | } 55 | 56 | /** 57 | * @return mixed[] 58 | */ 59 | public function getData(): array 60 | { 61 | return $this->data; 62 | } 63 | 64 | public function __toString(): string 65 | { 66 | return 'Generic event'; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/DrupalOrg/IssueGraph/Events/IssueEventInterface.php: -------------------------------------------------------------------------------- 1 | comment; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/DrupalOrg/IssueGraph/Events/MergeRequestCreateEvent.php: -------------------------------------------------------------------------------- 1 | mergeRequestId; 27 | } 28 | 29 | public function mergeRequestUrl(): string 30 | { 31 | return $this->mergeRequestUrl; 32 | } 33 | 34 | public function project(): string 35 | { 36 | return $this->project; 37 | } 38 | 39 | /** 40 | * The SSH URL. 41 | */ 42 | public function getGitUrl(): string 43 | { 44 | return $this->repoUrlGit; 45 | } 46 | 47 | public function getGitHttpUrl(): string 48 | { 49 | return $this->repoUrlHttp; 50 | } 51 | 52 | public function getGitBranch(): string 53 | { 54 | return $this->branch; 55 | } 56 | 57 | public function getCloneCommand(): string 58 | { 59 | return sprintf('git clone -b %s %s', $this->getGitBranch(), $this->getGitUrl()); 60 | } 61 | 62 | public function __toString(): string 63 | { 64 | return sprintf('Merge request !%d created: %s', $this->mergeRequestId(), $this->mergeRequestUrl()); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/DrupalOrg/IssueGraph/Events/StatusChangeEvent.php: -------------------------------------------------------------------------------- 1 | from = trim($from, " \t\n\r\0\x0B»"); 26 | $this->to = trim($to, " \t\n\r\0\x0B»"); 27 | } 28 | 29 | public function from(): string 30 | { 31 | return $this->from; 32 | } 33 | 34 | public function to(): string 35 | { 36 | return $this->to; 37 | } 38 | 39 | public function __toString(): string 40 | { 41 | return sprintf('Status change from %s to %s', $this->from(), $this->to()); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/DrupalOrg/IssueGraph/Events/TestResultEvent.php: -------------------------------------------------------------------------------- 1 | version; 20 | } 21 | 22 | public function result(): string 23 | { 24 | return $this->result; 25 | } 26 | 27 | public function __toString(): string 28 | { 29 | $result = $this->result(); 30 | $result = sprintf('%s %s', str_contains($result, 'fail') ? '❌' : '✅', $result); 31 | 32 | return sprintf( 33 | '🧪 Test result: %s: %s', 34 | $this->version(), 35 | $result, 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/DrupalOrg/IssueGraph/Events/VersionChangeEvent.php: -------------------------------------------------------------------------------- 1 | from, &$this->to] as &$version) { 16 | $version = trim($version, " \t\n\r\0\x0B»"); 17 | if (str_ends_with($version, '-dev')) { 18 | $version = substr($version, 0, -4); 19 | } 20 | } 21 | } 22 | 23 | /** 24 | * @return string 25 | * For example '9.1.x'. 26 | */ 27 | public function from(): string 28 | { 29 | return $this->from; 30 | } 31 | 32 | /** 33 | * @return string 34 | * For example '9.2.x'. 35 | */ 36 | public function to(): string 37 | { 38 | return $this->to; 39 | } 40 | 41 | public function __toString(): string 42 | { 43 | return sprintf('Version changed from %s to %s', $this->from(), $this->to()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/DrupalOrg/Objects/DrupalOrgComment.php: -------------------------------------------------------------------------------- 1 | isStub) { 26 | throw new \DomainException('Data missing for stubs.'); 27 | } 28 | $timestamp = $this->data->created ?? throw new \DomainException('Missing created date'); 29 | 30 | return new \DateTimeImmutable('@' . $timestamp); 31 | } 32 | 33 | /** 34 | * @return \dogit\DrupalOrg\Objects\DrupalOrgFile[] 35 | */ 36 | public function getFiles(): array 37 | { 38 | $this->files ?? throw new \DomainException('Data missing for stubs.'); 39 | 40 | return $this->files; 41 | } 42 | 43 | /** 44 | * @param \dogit\DrupalOrg\Objects\DrupalOrgFile[] $files 45 | */ 46 | public function setFiles(array $files): static 47 | { 48 | // Rekey by ID so result of \dogit\Utility::getFilesFromComments is 49 | // usable with iterator_to_array without $preserve_keys = FALSE. 50 | $this->files = array_combine(array_map(fn (DrupalOrgFile $file): int => $file->id(), $files), $files); 51 | 52 | return $this; 53 | } 54 | 55 | public function isBot(): bool 56 | { 57 | return 'System Message' === $this->getAuthorName(); 58 | } 59 | 60 | public function getAuthorName(): string 61 | { 62 | return $this->data->name ?? throw new \DomainException('Data missing for stubs.'); 63 | } 64 | 65 | public function getAuthorId(): int 66 | { 67 | return (int) ($this->data->author->id ?? throw new \DomainException('Data missing for stubs.')); 68 | } 69 | 70 | public function getIssue(): DrupalOrgIssue 71 | { 72 | if ($this->isStub) { 73 | throw new \DomainException('Data missing for stubs.'); 74 | } 75 | 76 | return $this->repository->share(DrupalOrgIssue::fromStub($this->data->node)); 77 | } 78 | 79 | public function getSequence(): int 80 | { 81 | return $this->sequence; 82 | } 83 | 84 | public function setSequence(int $sequence): static 85 | { 86 | $this->sequence = $sequence; 87 | 88 | return $this; 89 | } 90 | 91 | public function getComment(): string 92 | { 93 | $commentBody = $this->data->comment_body ?? throw new \DomainException('Data missing for stubs.'); 94 | 95 | return isset($commentBody->value) && strlen($commentBody->value) > 0 ? $commentBody->value : ''; 96 | } 97 | 98 | /** 99 | * @return $this 100 | */ 101 | public function importResponse(ResponseInterface $response): static 102 | { 103 | parent::importResponse($response); 104 | $this->setFiles(array_map( 105 | fn (\stdClass $file): DrupalOrgFile => $this->repository->share(DrupalOrgFile::fromStub($file)), 106 | $this->data->comment_files ?? [], 107 | )); 108 | 109 | return $this; 110 | } 111 | 112 | public static function fromStub(\stdClass $data): static 113 | { 114 | $data->id ?? throw new \InvalidArgumentException('ID is required'); 115 | // References from issues to comments are 'id' not 'cid'. 116 | $instance = new static((int) $data->id); 117 | $instance->stubData = $data; 118 | $instance->isStub = true; 119 | 120 | return $instance; 121 | } 122 | 123 | public static function fromResponse(ResponseInterface $response, DrupalOrgObjectRepository $repository): static 124 | { 125 | $data = json_decode((string) $response->getBody()); 126 | // ID's in responses are 'cid', not 'id' per fromStub(). 127 | $instance = new static((int) $data->cid); 128 | $instance 129 | ->setRepository($repository) 130 | ->importResponse($response); 131 | 132 | return $instance; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/DrupalOrg/Objects/DrupalOrgFile.php: -------------------------------------------------------------------------------- 1 | isStub) { 21 | throw new \DomainException('Data missing for stubs.'); 22 | } 23 | 24 | return $this->data->mime ?? throw new \DomainException('Missing mime type'); 25 | } 26 | 27 | public function getCreated(): \DateTimeImmutable 28 | { 29 | if ($this->isStub) { 30 | throw new \DomainException('Data missing for stubs.'); 31 | } 32 | $timestamp = $this->data->timestamp ?? throw new \DomainException('Missing created date'); 33 | 34 | return new \DateTimeImmutable('@' . $timestamp); 35 | } 36 | 37 | public function getUrl(): string 38 | { 39 | if ($this->isStub) { 40 | throw new \DomainException('Data missing for stubs.'); 41 | } 42 | 43 | return $this->data->url ?? throw new \DomainException('Missing URL'); 44 | } 45 | 46 | public function getParent(): ?DrupalOrgObject 47 | { 48 | return $this->parent; 49 | } 50 | 51 | public function setParent(DrupalOrgObject $object): static 52 | { 53 | $this->parent = $object; 54 | 55 | return $this; 56 | } 57 | 58 | public static function fromStub(\stdClass $data): static 59 | { 60 | $data->id ?? throw new \InvalidArgumentException('ID is required'); 61 | $instance = new static((int) $data->id); 62 | $instance->stubData = $data; 63 | $instance->isStub = true; 64 | 65 | return $instance; 66 | } 67 | 68 | public static function fromResponse(ResponseInterface $response, DrupalOrgObjectRepository $repository): static 69 | { 70 | $data = json_decode((string) $response->getBody()); 71 | $instance = new static((int) $data->fid); 72 | $instance 73 | ->setRepository($repository) 74 | ->importResponse($response); 75 | 76 | return $instance; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/DrupalOrg/Objects/DrupalOrgIssue.php: -------------------------------------------------------------------------------- 1 | isStub) { 21 | throw new \DomainException('Data missing for stubs.'); 22 | } 23 | $timestamp = $this->data->created ?? throw new \DomainException('Missing created date'); 24 | 25 | return new \DateTimeImmutable('@' . $timestamp); 26 | } 27 | 28 | public function getCurrentVersion(): string 29 | { 30 | if ($this->isStub) { 31 | throw new \DomainException('Data missing for stubs.'); 32 | } 33 | 34 | return $this->data->field_issue_version ?? throw new \DomainException('Missing issue version'); 35 | } 36 | 37 | /** 38 | * @return \dogit\DrupalOrg\Objects\DrupalOrgComment[] 39 | */ 40 | public function commentsWithFiles(): array 41 | { 42 | $cids = array_unique(array_filter(array_map( 43 | fn (\stdClass $file) => (int) ($file->file->cid ?? null), 44 | $this->data->field_issue_files, 45 | ))); 46 | 47 | return array_map( 48 | fn (int $cid) => $this->repository->share(DrupalOrgComment::fromStub((object) ['id' => $cid])), 49 | $cids 50 | ); 51 | } 52 | 53 | public function getProjectName(): string 54 | { 55 | return $this->data->field_project->machine_name ?? throw new \DomainException('Data missing for stubs.'); 56 | } 57 | 58 | public function getTitle(): string 59 | { 60 | return $this->data->title ?? throw new \DomainException('Data missing for stubs.'); 61 | } 62 | 63 | /** 64 | * @return \dogit\DrupalOrg\Objects\DrupalOrgComment[] 65 | * Ordered chronologically 66 | */ 67 | public function getComments(): array 68 | { 69 | return array_map( 70 | fn (\stdClass $comment) => $this->repository->share(DrupalOrgComment::fromStub($comment)), 71 | $this->data->comments ?? [], 72 | ); 73 | } 74 | 75 | /** 76 | * @return int[] 77 | */ 78 | public function getFiles(): array 79 | { 80 | return array_unique(array_filter(array_map( 81 | function (\stdClass $file) { 82 | $id = $file->file->id; 83 | 84 | return isset($id) ? (int) $id : null; 85 | }, 86 | $this->data->field_issue_files, 87 | ))); 88 | } 89 | 90 | /** 91 | * Patches ordered by comment and patch order within each comment. 92 | * 93 | * @return \Generator|\dogit\DrupalOrg\Objects\DrupalOrgPatch[] 94 | */ 95 | public function getPatches(DrupalOrgObjectIterator $objectIterator): \Generator 96 | { 97 | $commentsWithPatches = Utility::filterCommentsWithPatches($objectIterator, $this->commentsWithFiles()); 98 | // Transform file objects into patch objects. 99 | foreach ($commentsWithPatches as [$comment, $patchFiles]) { 100 | foreach ($patchFiles as $file) { 101 | yield DrupalOrgPatch::fromFile($file)->setParent($comment); 102 | } 103 | } 104 | } 105 | 106 | public static function fromStub(\stdClass $data): static 107 | { 108 | $id = $data->id ?? throw new \InvalidArgumentException('ID is required'); 109 | $instance = new static((int) $id); 110 | unset($data->id); 111 | $instance->stubData = $data; 112 | $instance->isStub = true; 113 | 114 | return $instance; 115 | } 116 | 117 | public static function fromResponse(ResponseInterface $response, DrupalOrgObjectRepository $repository): static 118 | { 119 | $data = json_decode((string) $response->getBody()); 120 | $instance = new static((int) $data->nid); 121 | $instance 122 | ->setRepository($repository) 123 | ->importResponse($response); 124 | 125 | return $instance; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/DrupalOrg/Objects/DrupalOrgObject.php: -------------------------------------------------------------------------------- 1 | id; 23 | } 24 | 25 | public function url(): string 26 | { 27 | return !$this->isStub ? $this->data->url : throw new \DomainException('Data missing for stubs.'); 28 | } 29 | 30 | public function isStub(): bool 31 | { 32 | return $this->isStub; 33 | } 34 | 35 | /** 36 | * Imports data into this instance. 37 | * 38 | * @return $this 39 | */ 40 | public function importResponse(ResponseInterface $response): static 41 | { 42 | $this->data = \json_decode((string) $response->getBody()); 43 | $this->isStub = false; 44 | 45 | return $this; 46 | } 47 | 48 | /** 49 | * @return $this 50 | */ 51 | public function setRepository(DrupalOrgObjectRepository $repository): static 52 | { 53 | $this->repository = $repository; 54 | 55 | return $this; 56 | } 57 | 58 | abstract public static function fromStub(\stdClass $data): static; 59 | 60 | /** 61 | * Creates a new object. 62 | */ 63 | abstract public static function fromResponse(ResponseInterface $response, DrupalOrgObjectRepository $repository): static; 64 | } 65 | -------------------------------------------------------------------------------- /src/DrupalOrg/Objects/DrupalOrgPatch.php: -------------------------------------------------------------------------------- 1 | version; 16 | } 17 | 18 | public function setVersion(string $version): static 19 | { 20 | $this->version = $version; 21 | 22 | return $this; 23 | } 24 | 25 | public function getGitReference(): string 26 | { 27 | return $this->gitReference; 28 | } 29 | 30 | public function setGitReference(string $gitReference): static 31 | { 32 | $this->gitReference = $gitReference; 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * @throws \LogicException 39 | * Throws a logic exception as we expect code to know whether patch has contents set or not 40 | */ 41 | public function getContents(): ?string 42 | { 43 | return $this->patchContents ?? throw new \LogicException('Missing patch contents'); 44 | } 45 | 46 | public function setContents(string $data): static 47 | { 48 | $this->patchContents = $data; 49 | 50 | return $this; 51 | } 52 | 53 | public function getParent(): DrupalOrgComment 54 | { 55 | $parent = parent::getParent(); 56 | assert($parent instanceof DrupalOrgComment); 57 | 58 | return $parent; 59 | } 60 | 61 | public static function fromFile(DrupalOrgFile $file): static 62 | { 63 | if ($file->isStub) { 64 | $patch = static::fromStub($file->stubData); 65 | } else { 66 | $patch = new static($file->id()); 67 | $patch->data = $file->data; 68 | $patch->isStub = false; 69 | } 70 | 71 | /** @var static $instance */ 72 | $instance = $file->repository->share($patch); 73 | 74 | return $instance; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Events/PatchToBranch/AbstractFilterEvent.php: -------------------------------------------------------------------------------- 1 | patches; 25 | } 26 | 27 | /** 28 | * @param \dogit\DrupalOrg\Objects\DrupalOrgPatch[] $patches 29 | * 30 | * @return $this 31 | */ 32 | public function setPatches(array $patches): static 33 | { 34 | $this->patches = $patches; 35 | 36 | return $this; 37 | } 38 | 39 | /** 40 | * @param callable $callback 41 | * A callable to apply to each patch. If the callable returns FALSE 42 | * then the patch will be removed. 43 | * 44 | * @return $this 45 | */ 46 | public function filter(callable $callback): static 47 | { 48 | $this->setPatches(array_filter( 49 | $this->getPatches(), 50 | $callback, 51 | )); 52 | 53 | return $this; 54 | } 55 | 56 | /** 57 | * @return $this 58 | */ 59 | public function setFailure(): self 60 | { 61 | $this->failure = true; 62 | 63 | return $this; 64 | } 65 | 66 | public function isPropagationStopped(): bool 67 | { 68 | return $this->failure; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Events/PatchToBranch/DogitEvent.php: -------------------------------------------------------------------------------- 1 | failure = true; 40 | 41 | return $this; 42 | } 43 | 44 | public function isPropagationStopped(): bool 45 | { 46 | return $this->failure; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Events/PatchToBranch/GitBranchEvent.php: -------------------------------------------------------------------------------- 1 | failure = true; 32 | 33 | return $this; 34 | } 35 | 36 | public function isPropagationStopped(): bool 37 | { 38 | return $this->failure; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Events/PatchToBranch/TerminateEvent.php: -------------------------------------------------------------------------------- 1 | stop = true; 30 | 31 | return $this; 32 | } 33 | 34 | public function isPropagationStopped(): bool 35 | { 36 | return $this->stop; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Events/PatchToBranch/VersionEvent.php: -------------------------------------------------------------------------------- 1 | failure = true; 33 | 34 | return $this; 35 | } 36 | 37 | public function isPropagationStopped(): bool 38 | { 39 | return $this->failure; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Flysystem2Storage.php: -------------------------------------------------------------------------------- 1 | filesystem = new Filesystem($adapter); 26 | } 27 | 28 | public function fetch($key) 29 | { 30 | if ($this->filesystem->fileExists($key)) { 31 | // The file exist, read it! 32 | $data = @unserialize( 33 | $this->filesystem->read($key) 34 | ); 35 | 36 | if ($data instanceof CacheEntry) { 37 | return $data; 38 | } 39 | } 40 | 41 | return null; 42 | } 43 | 44 | public function save($key, CacheEntry $data) 45 | { 46 | $this->filesystem->write($key, serialize($data)); 47 | 48 | return true; 49 | } 50 | 51 | public function delete($key) 52 | { 53 | try { 54 | $this->filesystem->delete($key); 55 | 56 | return true; 57 | } catch (UnableToDeleteFile $ex) { 58 | return false; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Git/CliRunner.php: -------------------------------------------------------------------------------- 1 | logger = $logger ?? new NullLogger(); 23 | $this->commandProcessor = $commandProcessor ?? new CommandProcessor(); 24 | } 25 | 26 | public function run($cwd, array $args, ?array $env = null) 27 | { 28 | foreach ($args as $arg) { 29 | if (!is_array($arg) || 2 !== count($arg)) { 30 | continue; 31 | } 32 | 33 | [$k, $v] = $arg; 34 | 35 | // Adds attribution to Dogit for Git merges. 36 | if ('--date' === $k) { 37 | if (!isset($env)) { 38 | $env = []; 39 | } 40 | 41 | $env['GIT_COMMITTER_NAME'] = 'dogit'; 42 | $env['GIT_COMMITTER_EMAIL'] = 'dogit@dogit.dev'; 43 | $env['GIT_COMMITTER_DATE'] = $v; 44 | } 45 | } 46 | 47 | $this->logger->debug('Executing command: {command}', [ 48 | 'command' => $this->commandProcessor->process('git', $args), 49 | ]); 50 | 51 | return parent::run($cwd, $args, $env); 52 | } 53 | 54 | public function setLogger(LoggerInterface $logger): void 55 | { 56 | $this->logger = $logger; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Git/CliRunnerInterface.php: -------------------------------------------------------------------------------- 1 | gitRepository->execute('status', '--porcelain')); 21 | } 22 | 23 | public function resetHard(?string $treeIsh = null): bool 24 | { 25 | $args = ['reset', '--hard', '--quiet']; 26 | if (null !== $treeIsh) { 27 | $args[] = $treeIsh; 28 | } 29 | 30 | return 0 === count($this->gitRepository->execute(...$args)); 31 | } 32 | 33 | public function clean(): void 34 | { 35 | $this->gitRepository->execute(['clean', '-f']); 36 | } 37 | 38 | public function checkoutNew(string $branchName, string $startPoint, bool $track = false): void 39 | { 40 | $args = ['checkout']; 41 | if ($track) { 42 | $args[] = '--track'; 43 | } 44 | $args[] = '-b'; 45 | $args[] = $branchName; 46 | $args[] = $startPoint; 47 | $this->gitRepository->execute(...$args); 48 | } 49 | 50 | public function checkoutPathspec(string $treeIsh, string $pathSpec): void 51 | { 52 | $this->gitRepository->execute([ 53 | 'checkout', 54 | $treeIsh, 55 | // @see https://git-scm.com/docs/git-checkout#Documentation/git-checkout.txt---. 56 | '--', 57 | $pathSpec, 58 | ]); 59 | } 60 | 61 | public function mergeStrategyOurs(string $hash): void 62 | { 63 | $this->gitRepository->merge($hash, [ 64 | '--strategy-option=ours', 65 | ]); 66 | } 67 | 68 | /** 69 | * @param mixed[] $options 70 | */ 71 | public function commit(array $options = []): void 72 | { 73 | $this->gitRepository->execute( 74 | 'commit', 75 | ...$options 76 | ); 77 | } 78 | 79 | public function getLastCommitId(): string 80 | { 81 | return (string) $this->gitRepository->getLastCommitId(); 82 | } 83 | 84 | public function addAllChanges(): void 85 | { 86 | $this->gitRepository->addAllChanges(); 87 | } 88 | 89 | public function branchExists(string $branchName): bool 90 | { 91 | try { 92 | $this->gitRepository->execute([ 93 | 'rev-parse', 94 | '--verify', 95 | '--quiet', 96 | $branchName, 97 | ]); 98 | } catch (GitException) { 99 | return false; 100 | } 101 | 102 | return true; 103 | } 104 | 105 | public function deleteBranch(string $branchName): void 106 | { 107 | $this->gitRepository->execute(...[ 108 | 'branch', 109 | '-D', 110 | $branchName, 111 | ]); 112 | } 113 | 114 | public function renameBranch(string $newBranchName, ?string $oldBranchName = null): void 115 | { 116 | $args = ['branch', '-M']; 117 | if (null !== $oldBranchName) { 118 | $args[] = $oldBranchName; 119 | } 120 | $args[] = $newBranchName; 121 | $this->gitRepository->execute(...$args); 122 | } 123 | 124 | /** 125 | * @return string[] 126 | */ 127 | public function getNewFiles(string $object): array 128 | { 129 | return $this->gitRepository->execute([ 130 | 'show', 131 | $object, 132 | '--name-only', 133 | '--diff-filter=AR', 134 | '--no-commit-id', 135 | ]); 136 | } 137 | 138 | /** 139 | * @return string[] 140 | */ 141 | public function getRemotes(): array 142 | { 143 | return $this->gitRepository->execute('remote'); 144 | } 145 | 146 | /** 147 | * @return string[] 148 | */ 149 | public function getRemoteUrls(string $remoteName): array 150 | { 151 | return $this->gitRepository->execute('remote', 'get-url', $remoteName); 152 | } 153 | 154 | public function addRemote(string $remoteName, string $gitUrl): void 155 | { 156 | $this->gitRepository->addRemote($remoteName, $gitUrl); 157 | } 158 | 159 | public function fetchRemote(string $remoteName): void 160 | { 161 | $this->gitRepository->fetch($remoteName); 162 | } 163 | 164 | public function checkoutNewTrackCustom(string $branch, string $remoteName, string $remoteBranch): void 165 | { 166 | $this->gitRepository->execute( 167 | 'checkout', 168 | '-b', 169 | $branch, 170 | '--track', 171 | sprintf('%s/%s', $remoteName, $remoteBranch), 172 | ); 173 | } 174 | 175 | /** 176 | * @return string[] 177 | */ 178 | public function execute(mixed ...$args): array 179 | { 180 | return $this->gitRepository->execute(...$args); 181 | } 182 | 183 | /** 184 | * @return string[] 185 | */ 186 | public function getBranches(): array 187 | { 188 | return array_filter((array) $this->gitRepository->getBranches()); 189 | } 190 | 191 | public function getRepositoryPath(): string 192 | { 193 | return $this->gitRepository->getRepositoryPath(); 194 | } 195 | 196 | /** 197 | * @throws GitException 198 | * On error, of if directory is not a Git repository 199 | */ 200 | public static function fromDirectory(Git $git, string $directory, Finder $finder): static 201 | { 202 | $gitFinder = $finder->directories()->in($directory); 203 | if (1 !== $gitFinder->ignoreVCS(false)->ignoreDotFiles(false)->depth(0)->name(['.git'])->count()) { 204 | throw new GitException('Directory is not the root of a Git repository.'); 205 | } 206 | 207 | $repo = $git->open($directory); 208 | 209 | return new static($repo); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/Git/GitResolver.php: -------------------------------------------------------------------------------- 1 | patch->getCreated(); 18 | // Returns a git hash. 19 | $return = $this->gitIo->execute([ 20 | 'rev-list', 21 | '-1', 22 | sprintf('--before="%s"', $created->getTimestamp()), 23 | // e.g 'remotes/origin/9.1.x'. 24 | sprintf('remotes/origin/%s', $this->patch->getGitReference()), 25 | ]); 26 | 27 | if (0 === count($return)) { 28 | throw new \Exception('Failed to get hash.'); 29 | } 30 | 31 | return reset($return); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/HttplugBrowser.php: -------------------------------------------------------------------------------- 1 | httpFactory = $httpFactory; 23 | $this->httpClient = $httpClient; 24 | } 25 | 26 | protected function doRequest(object $request): Response 27 | { 28 | assert($request instanceof RequestInterface || $request instanceof Request); 29 | 30 | $response = $this->httpClient->sendAsyncRequest( 31 | $this->httpFactory->createRequest($request->getMethod(), $request->getUri()) 32 | )->wait(); 33 | 34 | return new Response( 35 | (string) $response->getBody(), 36 | $response->getStatusCode(), 37 | $response->getHeaders(), 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/Filter/ByConstraintOption.php: -------------------------------------------------------------------------------- 1 | logger->info('Filtering patches by constraint argument.'); 19 | 20 | $versionConstraints = $event->options->versionConstraints; 21 | if (strlen($versionConstraints) > 0) { 22 | $event->filter(fn (DrupalOrgPatch $patch): bool => Semver::satisfies( 23 | str_replace('.x', '.9999', $patch->getVersion()), 24 | $versionConstraints, 25 | )); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/Filter/ByExcludedCommentOption.php: -------------------------------------------------------------------------------- 1 | logger->info('Filtering patches by excluded comments options.'); 19 | 20 | try { 21 | $filters = Utility::numericConstraintRuleBuilder($event->options->excludeComments); 22 | } catch (\InvalidArgumentException $e) { 23 | $event->logger->error(sprintf('Failed to process numeric constraints: %s', $e->getMessage())); 24 | $event->setFailure(); 25 | 26 | return; 27 | } 28 | 29 | // Remove patches for excluded comments. 30 | $event->filter(function (DrupalOrgPatch $patch) use ($filters, $event): bool { 31 | foreach ($filters as $filter) { 32 | // If the callback matches then exclude. 33 | $sequence = $patch->getParent()->getSequence(); 34 | if (true === $filter($sequence)) { 35 | $event->logger->debug(sprintf('Removed patches for comment #%s since it matched an exclusion constraint.', $sequence)); 36 | 37 | return false; 38 | } 39 | } 40 | 41 | return true; 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/Filter/ByLastOption.php: -------------------------------------------------------------------------------- 1 | options->onlyLastPatch) { 18 | return; 19 | } 20 | 21 | $event->logger->info('Removing all patches except the last.'); 22 | 23 | $patches = $event->getPatches(); 24 | if (count($patches) <= 1) { 25 | return; 26 | } 27 | 28 | $lastPatch = array_pop($patches); 29 | $event->setPatches([$lastPatch]); 30 | 31 | $removedCommentIds = array_map(fn (DrupalOrgPatch $patch): int => $patch->getParent()->getSequence(), $patches); 32 | $event->logger->info(sprintf( 33 | 'Filtered patches for comments %s leaving patch for comment %d.', 34 | implode(', ', $removedCommentIds), 35 | $lastPatch->getParent()->getSequence(), 36 | )); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/Filter/ByMetadata.php: -------------------------------------------------------------------------------- 1 | logger->info('Filtering patches by patch metadata.'); 20 | 21 | $logger = $event->logger; 22 | $issueEvents = $event->issueEvents; 23 | 24 | // Compute confidence upfront so these are all logged together. 25 | $event->filter(function (DrupalOrgPatch $patch) use ($logger, $issueEvents): bool { 26 | // Get tests results for this comment. 27 | /** @var \dogit\DrupalOrg\IssueGraph\Events\TestResultEvent[] $testResults */ 28 | $testResults = array_filter( 29 | $issueEvents, 30 | fn (IssueEventInterface $event): bool => $event instanceof TestResultEvent && $event->getComment()->id() == $patch->getParent()->id(), 31 | ); 32 | 33 | $keep = true; 34 | 35 | foreach ($testResults as $testResult) { 36 | if (str_contains($testResult->result(), 'Unable to apply patch')) { 37 | $keep = false; 38 | $logger->debug('Comment #{comment_id}: {patch_url} failed to apply during test run.', [ 39 | 'comment_id' => $patch->getParent()->getSequence(), 40 | 'patch_url' => $patch->getUrl(), 41 | ]); 42 | } 43 | } 44 | 45 | if (str_contains($patch->getUrl(), 'interdiff')) { 46 | $keep = false; 47 | $logger->debug('Comment #{comment_id}: {patch_url} looks like an interdiff', [ 48 | 'comment_id' => $patch->getParent()->getSequence(), 49 | 'patch_url' => $patch->getUrl(), 50 | ]); 51 | } 52 | 53 | if (str_contains($patch->getUrl(), 'test-only') || str_contains($patch->getUrl(), 'testonly')) { 54 | $keep = false; 55 | $logger->debug('Comment #{comment_id}: {patch_url} looks like a test only patch', [ 56 | 'comment_id' => $patch->getParent()->getSequence(), 57 | 'patch_url' => $patch->getUrl(), 58 | ]); 59 | } 60 | 61 | return $keep; 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/Filter/PrimaryTrunk.php: -------------------------------------------------------------------------------- 1 | logger->info('Constructing trunk.'); 19 | 20 | // @todo allow building non-linear tree. 21 | /** @var \dogit\DrupalOrg\Objects\DrupalOrgPatch[] $secondaries */ 22 | $secondaries = []; 23 | 24 | // Compute Version high-water mark to figure out linear graph of 25 | // patches. 26 | /** @var string|null $versionHwm */ 27 | $versionHwm = null; 28 | $event->filter(function (DrupalOrgPatch $patch) use (&$versionHwm, &$secondaries): bool { 29 | $patchVersion = $patch->getVersion(); 30 | if (null !== $versionHwm && strlen($versionHwm) > 0 && Comparator::lessThan($patchVersion, $versionHwm)) { 31 | // Skip this one. 32 | $secondaries[] = $patch; 33 | 34 | return false; 35 | } 36 | 37 | $versionHwm = $patchVersion; 38 | 39 | return true; 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/FilterByResponse/ByBody.php: -------------------------------------------------------------------------------- 1 | logger; 18 | 19 | // Filter out patches based on patch download response. 20 | $event->filter(function (DrupalOrgPatch $patch) use ($logger): bool { 21 | // Skip empty, like 2350939-88 22 | 23 | try { 24 | $contents = $patch->getContents(); 25 | } catch (\LogicException $e) { 26 | throw new \LogicException(sprintf('Missing patch contents for patch #%s %s', $patch->getParent()->getSequence(), $patch->getUrl()), 0, $e); 27 | } 28 | 29 | if (null === $contents || 0 === strlen($contents)) { 30 | $logger->debug('Removed empty patch #{comment_id} {patch_url}.', [ 31 | 'comment_id' => $patch->getParent()->getSequence(), 32 | 'patch_url' => $patch->getUrl(), 33 | ]); 34 | 35 | return false; 36 | } 37 | 38 | return true; 39 | }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/GitBranch/GitBranch.php: -------------------------------------------------------------------------------- 1 | options->branchName; 19 | 20 | if (null === $branchName || 0 === strlen($branchName)) { 21 | $branchName = 'dogit-' . $event->issue->id() . '-' . $event->initialGitReference; 22 | } 23 | 24 | $deleteBranchName = null; 25 | if ($event->gitIo->branchExists($branchName)) { 26 | if ($event->options->branchDeleteExisting) { 27 | // Rename branch in case it's checked out 28 | $event->logger->debug('Renaming existing branch {branch_name} so it can be deleted after a new branch with the same name is created.', [ 29 | 'branch_name' => $branchName, 30 | ]); 31 | $deleteBranchName = $this->branchToDeleteSuffix($branchName); 32 | $event->gitIo->renameBranch($deleteBranchName, $branchName); 33 | } else { 34 | $event->logger 35 | ->error(sprintf('Git branch %s already exists from a previous run. Specify a unique branch name with --branch or use --delete-existing-branch.', $branchName)); 36 | $event->setFailure(); 37 | 38 | return; 39 | } 40 | } 41 | 42 | $event->logger->info(sprintf('Starting branch at %s', $event->initialGitReference)); 43 | $event->gitIo->clean(); 44 | $event->gitIo->checkoutNew($branchName, 'origin/' . $event->initialGitReference); 45 | $event->logger->info('Checked out branch: ' . $branchName); 46 | 47 | if (null !== $deleteBranchName && strlen($deleteBranchName) > 0) { 48 | $event->logger->info('Deleting old branch {branch_name}', [ 49 | 'branch_name' => $deleteBranchName, 50 | ]); 51 | $event->gitIo->deleteBranch($deleteBranchName); 52 | } 53 | } 54 | 55 | private function branchToDeleteSuffix(string $branchName): string 56 | { 57 | return null !== $this->branchToDeleteSuffixGenerator 58 | ? ($this->branchToDeleteSuffixGenerator)($branchName) 59 | : $branchName . '-to-delete-' . (string) time(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/Terminate/EndMessage.php: -------------------------------------------------------------------------------- 1 | isSuccess) { 14 | $event->io->error('Error'); 15 | 16 | return; 17 | } 18 | 19 | $event->io->success('Done'); 20 | $event->io->note('If you are considering uploading to drupal.org, please review each commit carefully.'); 21 | $event->io->note('When pushing branches created by Dogit, maintainers may not delegate issue credit for work created by automatic tooling such as Dogit. Justify your case for credit as a comment if considerable manual effort was required to build the branch.'); 22 | $event->io->note('Additionally, it\'s helpful to share the exact command you used to construct the branch. Especially if version constraints, comment exclusions, or patch exclusions were used.'); 23 | $event->io->note('✨ Don\'t forget to like and subscribe — dogit.dev ✨'); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/Terminate/Statistics.php: -------------------------------------------------------------------------------- 1 | io->isDebug()) { 18 | return; 19 | } 20 | 21 | $objectCounter = []; 22 | foreach ($event->repository->all() as $object) { 23 | $objectCounter[$object::class] = ($objectCounter[$object::class] ?? 0) + 1; 24 | } 25 | $event->io->definitionList( 26 | 'Object statistics', 27 | ['Issues' => $objectCounter[DrupalOrgIssue::class] ?? 0], 28 | ['Comments' => $objectCounter[DrupalOrgComment::class] ?? 0], 29 | ['Files' => $objectCounter[DrupalOrgFile::class] ?? 0], 30 | ['Patches' => $objectCounter[DrupalOrgPatch::class] ?? 0], 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/ValidateLocalRepository/IsClean.php: -------------------------------------------------------------------------------- 1 | gitIo->isClean()) { 18 | return; 19 | } 20 | 21 | $event->logger->warning('Git working copy is not clean.'); 22 | if ($event->options->resetUnclean) { 23 | if ($event->gitIo->resetHard()) { 24 | $event->logger->info('Git working copy hard reset'); 25 | } else { 26 | $event->logger->error('Git working copy is still unclean after attempting to reset. Resolve manually.'); 27 | $event->setIsInvalid(); 28 | } 29 | } else { 30 | $event->logger->error('Git working copy is unclean. Use --reset to automatically clean.'); 31 | $event->setIsInvalid(); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/ValidateLocalRepository/IsGit.php: -------------------------------------------------------------------------------- 1 | gitIo->getBranches())) { 17 | $event->logger->error(sprintf('Directory %s does not look like a Git repository.', $event->gitDirectory)); 18 | $event->setIsInvalid(); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/Version/ByTestResultsEvent.php: -------------------------------------------------------------------------------- 1 | logger->debug('Checking patch versions by test result version.'); 18 | 19 | // Interpret all versions upfront then quit afterwards if there are any failures. 20 | $errors = 0; 21 | 22 | // Resolve suspicious versions. 23 | // Where we resolved a comment having a version in the timeline, but 24 | // we detect a differing version from test results. 25 | foreach ($event->patches as $patch) { 26 | // Get tests results for this comment. 27 | $testResults = array_filter( 28 | $event->issueEvents, 29 | fn (IssueEventInterface $event): bool => $event instanceof TestResultEvent && ($event->getComment()->id() == $patch->getParent()->id()) && strlen($event->version()) > 0, 30 | ); 31 | 32 | $message = '[Untested]'; 33 | if (count($testResults) > 0) { 34 | // Determine whether the patch version derived from issue version can be found in the test results. 35 | try { 36 | $satisfied = count(array_filter( 37 | $testResults, 38 | fn (TestResultEvent $testResult) => Semver::satisfies( 39 | Utility::normalizeSemverVersion($testResult->version()) . '-dev', 40 | $patch->getVersion() . '-dev', 41 | ), 42 | )) > 0; 43 | } catch (\UnexpectedValueException $e) { 44 | ++$errors; 45 | $event->logger->debug('Comment #{comment_id}: failed to interpret version for {patch_url}: Failed to interpret version: {message}', [ 46 | 'comment_id' => $patch->getParent()->getSequence(), 47 | 'patch_url' => $patch->getUrl(), 48 | 'message' => $e->getMessage(), 49 | ]); 50 | continue; 51 | } 52 | 53 | $testResultVersions = []; 54 | if (!$satisfied) { 55 | $testResultVersions = array_map( 56 | fn (TestResultEvent $event) => $event->version(), 57 | $testResults, 58 | ); 59 | // Pick the first version in test results instead. 60 | $guessedVersion = reset($testResultVersions); 61 | $patch->setVersion($guessedVersion); 62 | } 63 | $message = $satisfied ? '' : sprintf("May be reroll for older, detected as '%s'", implode(', ', $testResultVersions)); 64 | } 65 | 66 | $event->logger->debug('Comment #{comment_id}: {patch_url} guessed as {version} ({git_reference}) {message}', [ 67 | 'comment_id' => $patch->getParent()->getSequence(), 68 | 'patch_url' => $patch->getUrl(), 69 | 'version' => $patch->getVersion(), 70 | 'git_reference' => $patch->getGitReference(), 71 | 'message' => (strlen($message) > 0) ? ": $message" : '', 72 | ]); 73 | } 74 | 75 | if ($errors > 0) { 76 | $event->setFailure(); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Listeners/PatchToBranch/Version/ByVersionChangeEvent.php: -------------------------------------------------------------------------------- 1 | logger->debug('Checking patch version by time.'); 16 | 17 | // Given a patches associated with comments, and events produced from an 18 | // issue graph, determine the issue version at the time each patch was 19 | // posted. 20 | $versionChangeEvents = IssueEvent::filterVersionChangeEvents($event->issueEvents); 21 | foreach ($event->patches as $patch) { 22 | // Version for patch at this point is the estimated version from the graph, which is in turn 23 | // based off issue version changes. 24 | $version = Utility::versionAt($event->objectIterator, $patch->getParent()->getCreated(), $versionChangeEvents); 25 | $patch 26 | ->setVersion(Utility::normalizeSemverVersion($version)) 27 | ->setGitReference(Utility::normalizeGitReferenceVersion($version)); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ProcessFactory.php: -------------------------------------------------------------------------------- 1 | getMockBuilder(Git::class) 30 | ->getMock(); 31 | $git->expects($this->once()) 32 | ->method('cloneRepository') 33 | ->with('git@git.drupal.org:project/foo_bar_baz.git', $testRepoDir, []); 34 | 35 | $runner = $this->getMockBuilder(IRunner::class)->getMock(); 36 | 37 | $command = $this->getMockBuilder(ProjectCloneCommand::class) 38 | ->onlyMethods(['git']) 39 | ->disableOriginalConstructor() 40 | ->getMock(); 41 | $command->expects($this->once()) 42 | ->method('git') 43 | ->willReturn($git); 44 | $finder = $this->createMock(Finder::class); 45 | $command->__construct($runner, $finder); 46 | 47 | $command->handlerStack()->push(new DogitGuzzleTestMiddleware()); 48 | $tester = new CommandTester($command); 49 | 50 | $result = $tester->execute([ 51 | ProjectCloneCommandOptions::ARGUMENT_PROJECT => 'foo_bar_baz', 52 | ProjectCloneCommandOptions::ARGUMENT_DIRECTORY => $testRepoDir, 53 | ]); 54 | $this->assertEquals(0, $result); 55 | $this->assertStringContainsString('[OK] Done', $tester->getDisplay()); 56 | } 57 | 58 | public function testCommandNoDirectory(): void 59 | { 60 | $git = $this->getMockBuilder(Git::class) 61 | ->getMock(); 62 | $git->expects($this->once()) 63 | ->method('cloneRepository') 64 | // Expect second arg to be the same directory name as the project name. 65 | ->with('git@git.drupal.org:project/foo_bar_baz.git', 'foo_bar_baz', []); 66 | 67 | $runner = $this->getMockBuilder(IRunner::class)->getMock(); 68 | 69 | $command = $this->getMockBuilder(ProjectCloneCommand::class) 70 | ->onlyMethods(['git']) 71 | ->disableOriginalConstructor() 72 | ->getMock(); 73 | $command->expects($this->once()) 74 | ->method('git') 75 | ->willReturn($git); 76 | $finder = $this->createMock(Finder::class); 77 | $command->__construct($runner, $finder); 78 | 79 | $command->handlerStack()->push(new DogitGuzzleTestMiddleware()); 80 | $tester = new CommandTester($command); 81 | 82 | $result = $tester->execute([ 83 | ProjectCloneCommandOptions::ARGUMENT_PROJECT => 'foo_bar_baz', 84 | ]); 85 | $this->assertEquals(0, $result); 86 | $this->assertStringContainsString('[OK] Done', $tester->getDisplay()); 87 | } 88 | 89 | public function testDirectoryAlreadyExists(): void 90 | { 91 | $testRepoDir = '/tmp/dogit-testing/fakedir'; 92 | 93 | $git = $this->getMockBuilder(Git::class) 94 | ->getMock(); 95 | $git->expects($this->once()) 96 | ->method('cloneRepository') 97 | ->with('git@git.drupal.org:project/foo_bar_baz.git', $testRepoDir, []) 98 | ->willThrowException(new GitException('Repo already exists in foo_bar_baz.')); 99 | 100 | $runner = $this->getMockBuilder(IRunner::class)->getMock(); 101 | 102 | $command = $this->getMockBuilder(ProjectCloneCommand::class) 103 | ->onlyMethods(['git']) 104 | ->disableOriginalConstructor() 105 | ->getMock(); 106 | $command->expects($this->once()) 107 | ->method('git') 108 | ->willReturn($git); 109 | $finder = $this->createMock(Finder::class); 110 | $command->__construct($runner, $finder); 111 | 112 | $command->handlerStack()->push(new DogitGuzzleTestMiddleware()); 113 | $tester = new CommandTester($command); 114 | 115 | $result = $tester->execute([ 116 | ProjectCloneCommandOptions::ARGUMENT_PROJECT => 'foo_bar_baz', 117 | ProjectCloneCommandOptions::ARGUMENT_DIRECTORY => $testRepoDir, 118 | ]); 119 | $this->assertEquals(01, $result); 120 | $this->assertStringContainsString('Unable to clone repository: Repo already exists in foo_bar_baz.', $tester->getDisplay()); 121 | $this->assertStringNotContainsString('[OK] Done', $tester->getDisplay()); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /tests/Commands/Traits/HttpTraitTest.php: -------------------------------------------------------------------------------- 1 | originalHttp($logger, $noHttpCache, $cookies); 33 | } 34 | }; 35 | 36 | $logger = \Mockery::mock(LoggerInterface::class); 37 | $logger->expects('log') 38 | ->with('debug', 'GET 200 http://example.com/foo [Cache MISS]'); 39 | $logger->expects('error') 40 | ->never(); 41 | 42 | [$httpFactory, $httpAsyncClient] = $command->http($logger); 43 | 44 | $command->handlerStack()->push(function (callable $handler): callable { 45 | return static function ($request, array $options) { 46 | return new FulfilledPromise( 47 | new Response(200, [ 48 | 'Content-Type' => 'text/plain', 49 | ], 'All requests are caught for tests'), 50 | ); 51 | }; 52 | }, 'request_killer'); 53 | 54 | $request = $httpFactory->createRequest('GET', 'http://example.com/foo'); 55 | $promise = $httpAsyncClient->sendAsyncRequest($request); 56 | $response = $promise->wait(); 57 | $this->assertEquals('All requests are caught for tests', (string) $response->getBody()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/DogitGuzzleGitlabTestMiddleware.php: -------------------------------------------------------------------------------- 1 | getHeader('Host')[0] ?? null; 26 | if ('git.drupalcode.org' !== $host) { 27 | throw new InvalidArgumentException('Unexpected request host: ' . $host); 28 | } 29 | 30 | $path = $request->getUri()->getPath(); 31 | 32 | if ('/api/v4/projects/project%2Ffoo_bar_baz' === $path) { 33 | return new FulfilledPromise( 34 | new Response(200, [ 35 | 'Content-Type' => 'application/json', 36 | ], TestUtilities::getFixture('gitlab/project-foo_bar_baz.json')), 37 | ); 38 | } elseif ('/api/v4/projects/13371337/merge_requests' === $path) { 39 | return new FulfilledPromise( 40 | new Response(200, [ 41 | 'Content-Type' => 'application/json', 42 | ], TestUtilities::getFixture('gitlab/merge_requests-foo_bar_baz.json')), 43 | ); 44 | } elseif ('/api/v4/projects/73253' === $path) { 45 | return new FulfilledPromise( 46 | new Response(200, [ 47 | 'Content-Type' => 'application/json', 48 | ], TestUtilities::getFixture('gitlab/project-mr.json')), 49 | ); 50 | } elseif ('/api/v4/projects/project%2Fnomrs' === $path) { 51 | return new FulfilledPromise( 52 | new Response(200, [ 53 | 'Content-Type' => 'application/json', 54 | ], TestUtilities::getFixture('gitlab/project-nomrs.json')), 55 | ); 56 | } elseif ('/api/v4/projects/13371338/merge_requests' === $path) { 57 | return new FulfilledPromise( 58 | new Response(200, [ 59 | 'Content-Type' => 'application/json', 60 | ], TestUtilities::getFixture('gitlab/merge_requests-nomrs.json')), 61 | ); 62 | } elseif ('/api/v4/projects/project%2Fproject-doesnt-exist' === $path) { 63 | $response = new Response(404, [ 64 | 'Content-Type' => 'application/json', 65 | ], '{"message":"404 Project Not Found"}'); 66 | throw RequestException::create($request, $response, null, [], null); 67 | } 68 | 69 | throw new InvalidArgumentException('Unhandled scenario.'); 70 | }; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/DogitGuzzleTestMiddleware.php: -------------------------------------------------------------------------------- 1 | getUri()->getPath(); 26 | 27 | if (str_contains($path, '/api-d7/node/11110003')) { 28 | return new FulfilledPromise( 29 | new Response(200, [], TestUtilities::getFixture('issue-11110003.json')), 30 | ); 31 | } 32 | if (str_contains($path, '/api-d7/node/11110002')) { 33 | return new FulfilledPromise( 34 | new Response(200, [], TestUtilities::getFixture('issue-11110002.json')), 35 | ); 36 | } elseif (str_contains($path, '/api-d7/node/')) { 37 | return new FulfilledPromise( 38 | new Response(200, [], TestUtilities::getFixture('issue.json')), 39 | ); 40 | } 41 | 42 | if (str_contains($path, '/api-d7/comment/')) { 43 | preg_match('/\/api-d7\/comment\/(?\d+)\.json/', $path, $matches); 44 | ['cid' => $cid] = $matches; 45 | 46 | return new FulfilledPromise( 47 | new Response(200, [], TestUtilities::getFixture(sprintf('comment-%s.json', $cid))), 48 | ); 49 | } 50 | 51 | if (str_contains($path, '/api-d7/file/22220999.json')) { 52 | $response = new Response(404, [ 53 | 'Content-Type' => 'text/html', 54 | ], 'NOT FOUND!!!!!!!'); 55 | throw RequestException::create($request, $response, null, [], null); 56 | } elseif (str_contains($path, '/api-d7/file/')) { 57 | preg_match('/\/api-d7\/file\/(?\d+)\.json/', $path, $matches); 58 | ['fid' => $fid] = $matches; 59 | 60 | return new FulfilledPromise( 61 | new Response(200, [], TestUtilities::getFixture(sprintf('file-%s.json', $fid))), 62 | ); 63 | } 64 | 65 | if (str_contains($path, '/project/drupal/issues/11110001')) { 66 | return new FulfilledPromise( 67 | new Response(200, [], TestUtilities::getFixture('issue-11110001.html')), 68 | ); 69 | } 70 | if (str_contains($path, '/project/drupal/issues/11110003')) { 71 | return new FulfilledPromise( 72 | new Response(200, [], TestUtilities::getFixture('issue-11110003.html')), 73 | ); 74 | } elseif (str_contains($path, '/project/drupal/issues/11110002')) { 75 | return new FulfilledPromise( 76 | new Response(200, [], TestUtilities::getFixture('issue-11110002.html')), 77 | ); 78 | } elseif (str_contains($path, '/project/drupal/issues/')) { 79 | return new FulfilledPromise( 80 | new Response(200, [], TestUtilities::getFixture('issue.html')), 81 | ); 82 | } 83 | 84 | if (str_contains($path, '/files/issues/')) { 85 | return new FulfilledPromise( 86 | new Response(200, [], TestUtilities::getFixture('test.patch')), 87 | ); 88 | } 89 | 90 | throw new InvalidArgumentException('Unhandled scenario.'); 91 | }; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /tests/DogitTestBase.php: -------------------------------------------------------------------------------- 1 | httpFactory = Psr17FactoryDiscovery::findRequestFactory(); 35 | $handlerStack = HandlerStack::create(); 36 | $handlerStack->push(new DogitGuzzleTestMiddleware(), 'test_middleware'); 37 | $this->httpAsyncClient = new Client(new GuzzleClient([ 38 | 'handler' => $handlerStack, 39 | ])); 40 | $this->repository = new DrupalOrgObjectRepository(); 41 | } 42 | 43 | /** 44 | * @covers ::getIssue 45 | */ 46 | public function testGetIssue(): void 47 | { 48 | $api = new DrupalApi($this->httpFactory, $this->httpAsyncClient, $this->repository); 49 | 50 | $issue = $api->getIssue(2350939); 51 | $this->assertFalse($issue->isStub()); 52 | $this->assertEquals(2350939, $issue->id()); 53 | } 54 | 55 | /** 56 | * @covers ::getCommentAsync 57 | */ 58 | public function testGetCommentAsync(): void 59 | { 60 | $api = new DrupalApi($this->httpFactory, $this->httpAsyncClient, $this->repository); 61 | 62 | $comment = DrupalOrgComment::fromStub((object) ['id' => 13370001]); 63 | $comment->setRepository($this->repository); 64 | $promise = $api->getCommentAsync($comment); 65 | 66 | $this->assertTrue($comment->isStub()); 67 | $response = $promise->wait(); 68 | $comment->importResponse($response); 69 | $this->assertFalse($comment->isStub()); 70 | $this->assertEquals(new \DateTimeImmutable('2014-05-13 19:40:00'), $comment->getCreated()); 71 | } 72 | 73 | /** 74 | * @covers ::getFileAsync 75 | */ 76 | public function testGetFileAsync(): void 77 | { 78 | $api = new DrupalApi($this->httpFactory, $this->httpAsyncClient, $this->repository); 79 | 80 | $file = new DrupalOrgFile(22220001); 81 | $file->setRepository($this->repository); 82 | $promise = $api->getFileAsync($file); 83 | 84 | $this->assertTrue($file->isStub()); 85 | $response = $promise->wait(); 86 | $file->importResponse($response); 87 | $this->assertFalse($file->isStub()); 88 | $this->assertEquals(new \DateTimeImmutable('2015-04-10 22:25:03'), $file->getCreated()); 89 | } 90 | 91 | /** 92 | * @covers ::getPatchFileAsync 93 | */ 94 | public function testGetPatchFileAsync(): void 95 | { 96 | $api = new DrupalApi($this->httpFactory, $this->httpAsyncClient, $this->repository); 97 | 98 | $file = DrupalOrgFile::fromResponse( 99 | new Response(200, [], TestUtilities::getFixture('file-22220001.json')), 100 | $this->repository, 101 | ); 102 | 103 | $patch = DrupalOrgPatch::fromFile($file); 104 | $promise = $api->getPatchFileAsync($patch); 105 | $response = $promise->wait(); 106 | $this->assertEquals('This is a sample patch file.', (string) $response->getBody()); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tests/DrupalOrg/DrupalOrgObjectCollectionTest.php: -------------------------------------------------------------------------------- 1 | $collection */ 21 | $collection = new DrupalOrgObjectCollection(); 22 | $this->assertCount(0, iterator_to_array($collection->all(), false)); 23 | 24 | $object1 = DrupalOrgIssue::fromStub((object) ['id' => 1]); 25 | $hash1 = spl_object_hash($object1); 26 | $collection->share($object1); 27 | $this->assertCount(1, iterator_to_array($collection->all(), false)); 28 | 29 | $object2 = DrupalOrgIssue::fromStub((object) ['id' => 2]); 30 | $collection->share($object2); 31 | $this->assertCount(2, iterator_to_array($collection->all(), false)); 32 | 33 | $object3 = DrupalOrgIssue::fromStub((object) ['id' => 1]); 34 | $hash3 = spl_object_hash($object3); 35 | $this->assertNotEquals($hash3, $hash1); 36 | $this->assertEquals($object1->id(), $object3->id()); 37 | // We assert here that when object3 has the same ID as object1 that: 38 | // - object1 is returned 39 | // - object3 is not added to the repository/collections. 40 | $object3return = $collection->share($object3); 41 | $this->assertCount(2, iterator_to_array($collection->all(), false)); 42 | $this->assertEquals($hash1, spl_object_hash($object3return)); 43 | } 44 | 45 | /** 46 | * @covers ::all 47 | */ 48 | public function testAll(): void 49 | { 50 | /** @var \dogit\DrupalOrg\DrupalOrgObjectCollection<\dogit\DrupalOrg\Objects\DrupalOrgIssue> $collection */ 51 | $collection = new DrupalOrgObjectCollection(); 52 | $ref1 = $collection->share(DrupalOrgIssue::fromStub((object) ['id' => 1])); 53 | $ref2 = $collection->share(DrupalOrgIssue::fromStub((object) ['id' => 2])); 54 | $ref3 = $collection->share(DrupalOrgIssue::fromStub((object) ['id' => 3])); 55 | $ref4 = $collection->share(DrupalOrgIssue::fromStub((object) ['id' => 1])); 56 | $this->assertCount(3, iterator_to_array($collection->all(), false)); 57 | 58 | // No references remaining cause WeakMap to release the object. 59 | unset($ref3); 60 | $this->assertCount(2, iterator_to_array($collection->all(), false)); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/DrupalOrg/DrupalOrgObjectIteratorTest.php: -------------------------------------------------------------------------------- 1 | httpFactory = Psr17FactoryDiscovery::findRequestFactory(); 41 | $handlerStack = HandlerStack::create(); 42 | $handlerStack->push(new DogitGuzzleTestMiddleware(), 'test_middleware'); 43 | $this->httpAsyncClient = new Client(new GuzzleClient([ 44 | 'handler' => $handlerStack, 45 | ])); 46 | $this->repository = new DrupalOrgObjectRepository(); 47 | } 48 | 49 | /** 50 | * @covers ::unstubComments 51 | * @covers ::filterStubbed 52 | * @covers ::unwrap 53 | */ 54 | public function testUnstubComments(): void 55 | { 56 | $api = new DrupalApi($this->httpFactory, $this->httpAsyncClient, $this->repository); 57 | 58 | $objectIterator = new DrupalOrgObjectIterator($api, $this->createMock(LoggerInterface::class)); 59 | 60 | $comments = []; 61 | $comments[] = DrupalOrgComment::fromStub((object) ['id' => 13370001])->setRepository($this->repository); 62 | $comments[] = DrupalOrgComment::fromStub((object) ['id' => 13370002])->setRepository($this->repository); 63 | // Duplicate ID's are filtered out. 64 | $comments[] = DrupalOrgComment::fromStub((object) ['id' => 13370001])->setRepository($this->repository); 65 | // Test even if an object is not a stub, it must be not filtered out. 66 | $nonStubComment = $this->createMock(DrupalOrgComment::class); 67 | $nonStubComment->method('id')->willReturn(13370003); 68 | $nonStubComment->method('isStub')->willReturn(false); 69 | $comments[] = $nonStubComment; 70 | 71 | $result = $objectIterator->unstubComments($comments); 72 | $this->assertCount(3, $result); 73 | $this->assertEquals(13370001, $result[0]->id()); 74 | $this->assertEquals(13370002, $result[1]->id()); 75 | $this->assertEquals(13370003, $result[3]->id()); 76 | } 77 | 78 | /** 79 | * @covers ::unstubFiles 80 | * @covers ::filterStubbed 81 | * @covers ::unwrap 82 | */ 83 | public function testUnstubFiles(): void 84 | { 85 | $api = new DrupalApi($this->httpFactory, $this->httpAsyncClient, $this->repository); 86 | 87 | $objectIterator = new DrupalOrgObjectIterator($api, $this->createMock(LoggerInterface::class)); 88 | 89 | $files = []; 90 | $files[] = DrupalOrgFile::fromStub((object) ['id' => 22220001])->setRepository($this->repository); 91 | $files[] = DrupalOrgFile::fromStub((object) ['id' => 22220002])->setRepository($this->repository); 92 | // Duplicate ID's are filtered out. 93 | $files[] = DrupalOrgFile::fromStub((object) ['id' => 22220001])->setRepository($this->repository); 94 | // Test even if an object is not a stub, it must be not filtered out. 95 | $nonStubComment = $this->createMock(DrupalOrgFile::class); 96 | $nonStubComment->method('id')->willReturn(22220003); 97 | $nonStubComment->method('isStub')->willReturn(false); 98 | $files[] = $nonStubComment; 99 | 100 | $result = $objectIterator->unstubFiles($files); 101 | $this->assertCount(3, $result); 102 | $this->assertEquals(22220001, $result[0]->id()); 103 | $this->assertEquals(22220002, $result[1]->id()); 104 | $this->assertEquals(22220003, $result[3]->id()); 105 | } 106 | 107 | /** 108 | * @covers ::downloadPatchFiles 109 | * @covers ::unwrap 110 | */ 111 | public function testDownloadPatchFiles(): void 112 | { 113 | $api = new DrupalApi($this->httpFactory, $this->httpAsyncClient, $this->repository); 114 | 115 | $objectIterator = new DrupalOrgObjectIterator($api, $this->createMock(LoggerInterface::class)); 116 | 117 | $files = []; 118 | $files[] = DrupalOrgPatch::fromResponse( 119 | new Response(200, [], TestUtilities::getFixture('file-22220001.json')), 120 | $this->repository, 121 | ); 122 | $files[] = DrupalOrgPatch::fromResponse( 123 | new Response(200, [], TestUtilities::getFixture('file-22220002.json')), 124 | $this->repository, 125 | ); 126 | $files[] = DrupalOrgPatch::fromResponse( 127 | new Response(200, [], TestUtilities::getFixture('file-22220001.json')), 128 | $this->repository, 129 | ); 130 | 131 | $result = $objectIterator->downloadPatchFiles($files); 132 | $this->assertEquals(22220001, $result[0]->id()); 133 | $this->assertEquals('This is a sample patch file.', $result[0]->getContents()); 134 | $this->assertEquals(22220002, $result[1]->id()); 135 | $this->assertEquals('This is a sample patch file.', $result[1]->getContents()); 136 | } 137 | 138 | /** 139 | * @covers ::unwrap 140 | */ 141 | public function testRequestFailure(): void 142 | { 143 | $api = new DrupalApi($this->httpFactory, $this->httpAsyncClient, $this->repository); 144 | 145 | $objectIterator = new DrupalOrgObjectIterator($api, $this->createMock(LoggerInterface::class)); 146 | 147 | $files = []; 148 | $file = DrupalOrgFile::fromStub((object) ['id' => '22220999']); 149 | $file->setRepository($this->repository); 150 | $files[] = $file; 151 | 152 | $this->expectException(HttpException::class); 153 | $this->expectExceptionMessage('Client error: `GET https://www.drupal.org/api-d7/file/22220999.json` resulted in a `404 Not Found` response: 154 | NOT FOUND!!!!!!!'); 155 | $objectIterator->unstubFiles($files); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /tests/DrupalOrg/DrupalOrgObjectRepositoryTest.php: -------------------------------------------------------------------------------- 1 | assertCount(0, iterator_to_array($repository->all(), false)); 22 | 23 | $object1 = DrupalOrgIssue::fromStub((object) ['id' => 1]); 24 | $hash1 = spl_object_hash($object1); 25 | $repository->share($object1); 26 | $this->assertCount(1, iterator_to_array($repository->all(), false)); 27 | 28 | $object2 = DrupalOrgIssue::fromStub((object) ['id' => 2]); 29 | $repository->share($object2); 30 | $this->assertCount(2, iterator_to_array($repository->all(), false)); 31 | 32 | $object3 = DrupalOrgIssue::fromStub((object) ['id' => 1]); 33 | $hash3 = spl_object_hash($object3); 34 | $this->assertNotEquals($hash3, $hash1); 35 | $this->assertEquals($object1->id(), $object3->id()); 36 | // We assert here that when object3 has the same ID as object1 that: 37 | // - object1 is returned 38 | // - object3 is not added to the repository/collections. 39 | $object3return = $repository->share($object3); 40 | $this->assertCount(2, iterator_to_array($repository->all(), false)); 41 | $this->assertEquals($hash1, spl_object_hash($object3return)); 42 | } 43 | 44 | /** 45 | * @covers ::all 46 | */ 47 | public function testAll(): void 48 | { 49 | $repository = new DrupalOrgObjectRepository(); 50 | $ref1 = $repository->share(DrupalOrgIssue::fromStub((object) ['id' => 1])); 51 | $ref2 = $repository->share(DrupalOrgIssue::fromStub((object) ['id' => 2])); 52 | $ref3 = $repository->share(DrupalOrgIssue::fromStub((object) ['id' => 3])); 53 | $ref4 = $repository->share(DrupalOrgIssue::fromStub((object) ['id' => 1])); 54 | $this->assertCount(3, iterator_to_array($repository->all(), false)); 55 | 56 | // No references remaining cause WeakMap to release the object. 57 | unset($ref3); 58 | $this->assertCount(2, iterator_to_array($repository->all(), false)); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/DrupalOrg/IssueGraph/DrupalOrgIssueGraphTest.php: -------------------------------------------------------------------------------- 1 | push(new DogitGuzzleTestMiddleware(), 'test_middleware'); 39 | $httpAsyncClient = new Client(new GuzzleClient([ 40 | 'handler' => $handlerStack, 41 | ])); 42 | $repository = new DrupalOrgObjectRepository(); 43 | $url = 'https://www.drupal.org/project/drupal/issues/2350939'; 44 | 45 | $events = iterator_to_array((new DrupalOrgIssueGraph( 46 | $httpFactory, 47 | $httpAsyncClient, 48 | $repository, 49 | $url, 50 | ))->graph()); 51 | 52 | $this->assertCount(19, $events); 53 | $this->assertInstanceOf(CommentEvent::class, $events[0]); 54 | $this->assertInstanceOf(StatusChangeEvent::class, $events[1]); 55 | $this->assertInstanceOf(TestResultEvent::class, $events[2]); 56 | $this->assertInstanceOf(CommentEvent::class, $events[3]); 57 | $this->assertInstanceOf(VersionChangeEvent::class, $events[4]); 58 | $this->assertInstanceOf(CommentEvent::class, $events[5]); 59 | $this->assertInstanceOf(CommentEvent::class, $events[6]); 60 | $this->assertInstanceOf(CommentEvent::class, $events[7]); 61 | $this->assertInstanceOf(TestResultEvent::class, $events[8]); 62 | $this->assertInstanceOf(CommentEvent::class, $events[9]); 63 | $this->assertInstanceOf(VersionChangeEvent::class, $events[10]); 64 | $this->assertInstanceOf(CommentEvent::class, $events[11]); 65 | $this->assertInstanceOf(TestResultEvent::class, $events[12]); 66 | $this->assertInstanceOf(CommentEvent::class, $events[13]); 67 | $this->assertInstanceOf(VersionChangeEvent::class, $events[14]); 68 | $this->assertInstanceOf(CommentEvent::class, $events[15]); 69 | $this->assertInstanceOf(MergeRequestCreateEvent::class, $events[16]); 70 | $this->assertInstanceOf(CommentEvent::class, $events[17]); 71 | $this->assertInstanceOf(MergeRequestCreateEvent::class, $events[18]); 72 | 73 | // Unstub comments so events can stringify. 74 | $api = new DrupalApi($httpFactory, $httpAsyncClient, $repository); 75 | $objectIterator = new DrupalOrgObjectIterator($api, $this->createMock(LoggerInterface::class)); 76 | $objectIterator->unstubComments(iterator_to_array(Utility::getCommentsFromEvents($events))); 77 | 78 | $this->assertEquals('🗣 Comment by 👤 larowlan on Tue, 13 May 2014 19:40:00 +0000', (string) $events[0]); 79 | $this->assertEquals('Status change from Needs work to Needs review', (string) $events[1]); 80 | $this->assertEquals('🧪 Test result: 8.2.x: ✅ PHP 7.1 & MySQL 5.7 26,400 pass', (string) $events[2]); 81 | $this->assertEquals('🗣 Comment by 👤 larowlan on Tue, 13 May 2014 22:26:40 +0000', (string) $events[3]); 82 | $this->assertEquals('Version changed from 8.2.x to 8.3.x', (string) $events[4]); 83 | $this->assertEquals('🗣 Comment by 👤 larowlan on Wed, 14 May 2014 01:13:20 +0000', (string) $events[5]); 84 | $this->assertEquals('🗣 Comment by 👤 larowlan on Wed, 14 May 2014 04:00:00 +0000', (string) $events[6]); 85 | $this->assertEquals('🗣 Comment by 👤 larowlan on Wed, 14 May 2014 06:46:40 +0000', (string) $events[7]); 86 | $this->assertEquals('🧪 Test result: 8.3.x: ✅ PHP 7.1 & MySQL 5.7 26,400 pass', (string) $events[8]); 87 | $this->assertEquals('🗣 Comment by 👤 larowlan on Wed, 14 May 2014 06:46:40 +0000', (string) $events[9]); 88 | $this->assertEquals('Version changed from 8.3.x to 8.4.x', (string) $events[10]); 89 | $this->assertEquals('🗣 Comment by 👤 larowlan on Wed, 14 May 2014 06:46:40 +0000', (string) $events[11]); 90 | $this->assertEquals('🧪 Test result: 8.4.x: ✅ PHP 7.1 & MySQL 5.7 26,400 pass', (string) $events[12]); 91 | $this->assertEquals('🗣 Comment by 👤 larowlan on Wed, 14 May 2014 15:06:40 +0000', (string) $events[13]); 92 | $this->assertEquals('Version changed from 8.4.x to 8.5.x', (string) $events[14]); 93 | $this->assertEquals('🗣 Comment by 🤖 System Message on Wed, 14 May 2014 15:06:40 +0000', (string) $events[15]); 94 | $this->assertEquals('Merge request !333 created: https://git.drupalcode.org/project/drupal/-/merge_requests/333', (string) $events[16]); 95 | } 96 | 97 | /** 98 | * @covers ::graph 99 | */ 100 | public function testWithoutMergeRequests(): void 101 | { 102 | $httpFactory = Psr17FactoryDiscovery::findRequestFactory(); 103 | $handlerStack = HandlerStack::create(); 104 | $handlerStack->push(new DogitGuzzleTestMiddleware(), 'test_middleware'); 105 | $httpAsyncClient = new Client(new GuzzleClient([ 106 | 'handler' => $handlerStack, 107 | ])); 108 | $repository = new DrupalOrgObjectRepository(); 109 | $url = 'https://www.drupal.org/project/drupal/issues/11110001'; 110 | 111 | $events = iterator_to_array((new DrupalOrgIssueGraph( 112 | $httpFactory, 113 | $httpAsyncClient, 114 | $repository, 115 | $url, 116 | ))->graph()); 117 | 118 | $this->assertCount(15, $events); 119 | $this->assertInstanceOf(CommentEvent::class, $events[0]); 120 | $this->assertInstanceOf(StatusChangeEvent::class, $events[1]); 121 | $this->assertInstanceOf(TestResultEvent::class, $events[2]); 122 | $this->assertInstanceOf(CommentEvent::class, $events[3]); 123 | $this->assertInstanceOf(VersionChangeEvent::class, $events[4]); 124 | $this->assertInstanceOf(CommentEvent::class, $events[5]); 125 | $this->assertInstanceOf(CommentEvent::class, $events[6]); 126 | $this->assertInstanceOf(CommentEvent::class, $events[7]); 127 | $this->assertInstanceOf(TestResultEvent::class, $events[8]); 128 | $this->assertInstanceOf(CommentEvent::class, $events[9]); 129 | $this->assertInstanceOf(VersionChangeEvent::class, $events[10]); 130 | $this->assertInstanceOf(CommentEvent::class, $events[11]); 131 | $this->assertInstanceOf(TestResultEvent::class, $events[12]); 132 | $this->assertInstanceOf(CommentEvent::class, $events[13]); 133 | $this->assertInstanceOf(VersionChangeEvent::class, $events[14]); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /tests/DrupalOrg/Objects/DrupalOrgCommentTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 23 | 1400010000, 24 | $this->createComment()->getCreated()->getTimestamp() 25 | ); 26 | 27 | $this->expectException(\DomainException::class); 28 | $this->expectExceptionMessage('Data missing for stubs.'); 29 | DrupalOrgComment::fromStub((object) ['id' => 1])->getCreated(); 30 | } 31 | 32 | /** 33 | * @covers ::getFiles 34 | */ 35 | public function testGetFiles(): void 36 | { 37 | $files = $this->createComment()->getFiles(); 38 | $this->assertCount(1, $files); 39 | $this->assertEquals(22220001, reset($files)->id()); 40 | 41 | $this->expectException(\DomainException::class); 42 | $this->expectExceptionMessage('Data missing for stubs.'); 43 | DrupalOrgComment::fromStub((object) ['id' => 1])->getFiles(); 44 | } 45 | 46 | /** 47 | * @covers ::setFiles 48 | */ 49 | public function testSetFiles(): void 50 | { 51 | $comment = $this->createComment(); 52 | $this->assertCount(1, $comment->getFiles()); 53 | $comment->setFiles([]); 54 | $this->assertCount(0, $comment->getFiles()); 55 | } 56 | 57 | /** 58 | * @covers ::isBot 59 | */ 60 | public function testIsBot(): void 61 | { 62 | $comment = $this->createComment('13370001'); 63 | $this->assertFalse($comment->isBot()); 64 | 65 | $comment = $this->createComment('13370009'); 66 | $this->assertTrue($comment->isBot()); 67 | 68 | $this->expectException(\DomainException::class); 69 | $this->expectExceptionMessage('Data missing for stubs.'); 70 | DrupalOrgComment::fromStub((object) ['id' => 1])->isBot(); 71 | } 72 | 73 | /** 74 | * @covers ::getAuthorName 75 | */ 76 | public function testGetAuthorName(): void 77 | { 78 | $comment = $this->createComment(); 79 | $this->assertEquals('larowlan', $comment->getAuthorName()); 80 | 81 | $this->expectException(\DomainException::class); 82 | $this->expectExceptionMessage('Data missing for stubs.'); 83 | DrupalOrgComment::fromStub((object) ['id' => 1])->getAuthorName(); 84 | } 85 | 86 | /** 87 | * @covers ::getAuthorId 88 | */ 89 | public function testGetAuthorId(): void 90 | { 91 | $comment = $this->createComment(); 92 | $this->assertEquals(395439, $comment->getAuthorId()); 93 | 94 | $this->expectException(\DomainException::class); 95 | $this->expectExceptionMessage('Data missing for stubs.'); 96 | DrupalOrgComment::fromStub((object) ['id' => 1])->getAuthorId(); 97 | } 98 | 99 | /** 100 | * @covers ::getIssue 101 | */ 102 | public function testGetIssue(): void 103 | { 104 | $comment = $this->createComment(); 105 | $issue = $comment->getIssue(); 106 | $this->assertEquals(2350939, $issue->id()); 107 | $this->assertTrue($issue->isStub()); 108 | 109 | $this->expectException(\DomainException::class); 110 | $this->expectExceptionMessage('Data missing for stubs.'); 111 | DrupalOrgComment::fromStub((object) ['id' => 1])->getIssue(); 112 | } 113 | 114 | /** 115 | * This is usually only set in the context of an issue, it is not known from API. 116 | * 117 | * @covers ::getSequence 118 | * @covers ::setSequence 119 | */ 120 | public function testSequence(): void 121 | { 122 | $comment = $this->createComment(); 123 | $comment->setSequence(33); 124 | $this->assertEquals(33, $comment->getSequence()); 125 | 126 | $this->expectException(\Error::class); 127 | $this->createComment()->getSequence(); 128 | } 129 | 130 | /** 131 | * @covers ::getComment 132 | */ 133 | public function testGetComment(): void 134 | { 135 | $this->assertEquals('Comment text', $this->createComment()->getComment()); 136 | 137 | $this->expectException(\DomainException::class); 138 | $this->expectExceptionMessage('Data missing for stubs.'); 139 | DrupalOrgComment::fromStub((object) ['id' => 1])->getComment(); 140 | } 141 | 142 | /** 143 | * @covers ::getComment 144 | */ 145 | public function testGetCommentNoData(): void 146 | { 147 | $this->assertEquals('', $this->createComment('13370010')->getComment()); 148 | 149 | $this->expectException(\DomainException::class); 150 | $this->expectExceptionMessage('Data missing for stubs.'); 151 | DrupalOrgComment::fromStub((object) ['id' => 1])->getComment(); 152 | } 153 | 154 | /** 155 | * @covers ::importResponse 156 | */ 157 | public function testImportResponse(): void 158 | { 159 | $comment = DrupalOrgComment::fromStub((object) ['id' => 1]); 160 | $comment->setRepository(new DrupalOrgObjectRepository()); 161 | $comment->importResponse(new Response(200, [], TestUtilities::getFixture('comment-13370001.json'))); 162 | $this->assertEquals(1400010000, $comment->getCreated()->getTimestamp()); 163 | } 164 | 165 | /** 166 | * @covers ::fromStub 167 | */ 168 | public function testFromStub(): void 169 | { 170 | $comment = DrupalOrgComment::fromStub((object) ['id' => 1]); 171 | $this->assertEquals(1, $comment->id()); 172 | 173 | $this->expectException(\InvalidArgumentException::class); 174 | $this->expectExceptionMessage('ID is required'); 175 | DrupalOrgComment::fromStub((object) [])->id(); 176 | } 177 | 178 | /** 179 | * @covers ::fromResponse 180 | */ 181 | public function testFromResponse(): void 182 | { 183 | $repository = new DrupalOrgObjectRepository(); 184 | $comment = DrupalOrgComment::fromResponse( 185 | new Response(200, [], TestUtilities::getFixture('comment-13370001.json')), 186 | $repository, 187 | ); 188 | $this->assertCount(1, iterator_to_array($repository->all(), false)); 189 | $this->assertEquals(1400010000, $comment->getCreated()->getTimestamp()); 190 | } 191 | 192 | protected function createComment(?string $commentFixtureId = null): DrupalOrgComment 193 | { 194 | $commentFixtureId = $commentFixtureId ?? '13370001'; 195 | $repository = new DrupalOrgObjectRepository(); 196 | 197 | return DrupalOrgComment::fromResponse( 198 | new Response(200, [], TestUtilities::getFixture(sprintf('comment-%s.json', $commentFixtureId))), 199 | $repository, 200 | ); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /tests/DrupalOrg/Objects/DrupalOrgFileTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 24 | 'text/x-diff', 25 | $this->createFile()->getMime() 26 | ); 27 | 28 | $this->expectException(\DomainException::class); 29 | $this->expectExceptionMessage('Data missing for stubs.'); 30 | DrupalOrgFile::fromStub((object) ['id' => 1])->getMime(); 31 | } 32 | 33 | /** 34 | * @covers ::getCreated 35 | */ 36 | public function testGetCreated(): void 37 | { 38 | $this->assertEquals( 39 | 1428704703, 40 | $this->createFile()->getCreated()->getTimestamp() 41 | ); 42 | 43 | $this->expectException(\DomainException::class); 44 | $this->expectExceptionMessage('Data missing for stubs.'); 45 | DrupalOrgFile::fromStub((object) ['id' => 1])->getCreated(); 46 | } 47 | 48 | /** 49 | * @covers ::getUrl 50 | */ 51 | public function testGetUrl(): void 52 | { 53 | $this->assertEquals( 54 | 'https://www.drupal.org/files/issues/alpha.patch', 55 | $this->createFile()->getUrl() 56 | ); 57 | 58 | $this->expectException(\DomainException::class); 59 | $this->expectExceptionMessage('Data missing for stubs.'); 60 | DrupalOrgFile::fromStub((object) ['id' => 1])->getUrl(); 61 | } 62 | 63 | /** 64 | * This is usually only set in the context of an issue, it is not known from API. 65 | * 66 | * @covers ::getParent 67 | * @covers ::setParent 68 | */ 69 | public function testParent(): void 70 | { 71 | $comment = new DrupalOrgComment(66); 72 | 73 | $file = $this->createFile(); 74 | $file->setParent($comment); 75 | /** @var DrupalOrgComment $parent */ 76 | $parent = $file->getParent(); 77 | $this->assertEquals(66, $parent->id()); 78 | 79 | $this->assertNull($this->createFile()->getParent()); 80 | } 81 | 82 | /** 83 | * @covers ::fromStub 84 | */ 85 | public function testFromStub(): void 86 | { 87 | $file = DrupalOrgFile::fromStub((object) ['id' => 1]); 88 | $this->assertEquals(1, $file->id()); 89 | 90 | $this->expectException(\InvalidArgumentException::class); 91 | $this->expectExceptionMessage('ID is required'); 92 | DrupalOrgFile::fromStub((object) [])->id(); 93 | } 94 | 95 | /** 96 | * @covers ::fromResponse 97 | */ 98 | public function testFromResponse(): void 99 | { 100 | $repository = new DrupalOrgObjectRepository(); 101 | $file = DrupalOrgFile::fromResponse( 102 | new Response(200, [], TestUtilities::getFixture('file-22220001.json')), 103 | $repository, 104 | ); 105 | $this->assertCount(0, iterator_to_array($repository->all(), false)); 106 | $this->assertEquals(1428704703, $file->getCreated()->getTimestamp()); 107 | } 108 | 109 | protected function createFile(?string $fixtureId = null): DrupalOrgFile 110 | { 111 | $fixtureId = $fixtureId ?? '22220001'; 112 | $repository = new DrupalOrgObjectRepository(); 113 | 114 | return DrupalOrgFile::fromResponse( 115 | new Response(200, [], TestUtilities::getFixture(sprintf('file-%s.json', $fixtureId))), 116 | $repository, 117 | ); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/DrupalOrg/Objects/DrupalOrgIssueTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 30 | 1412524021, 31 | $this->createIssue()->getCreated()->getTimestamp() 32 | ); 33 | 34 | $this->expectException(\DomainException::class); 35 | $this->expectExceptionMessage('Data missing for stubs.'); 36 | DrupalOrgIssue::fromStub((object) ['id' => 1])->getCreated(); 37 | } 38 | 39 | /** 40 | * @covers ::getCurrentVersion 41 | */ 42 | public function testGetCurrentVersion(): void 43 | { 44 | $this->assertEquals('9.3.x-dev', $this->createIssue()->getCurrentVersion()); 45 | 46 | $this->expectException(\DomainException::class); 47 | $this->expectExceptionMessage('Data missing for stubs.'); 48 | DrupalOrgIssue::fromStub((object) ['id' => 1])->getCurrentVersion(); 49 | } 50 | 51 | /** 52 | * @covers ::commentsWithFiles 53 | */ 54 | public function testCommentsWithFiles(): void 55 | { 56 | $comments = $this->createIssue()->commentsWithFiles(); 57 | $this->assertCount(3, $comments); 58 | $this->assertEquals(13370001, $comments[0]->id()); 59 | $this->assertEquals(13370005, $comments[1]->id()); 60 | $this->assertEquals(13370007, $comments[2]->id()); 61 | } 62 | 63 | /** 64 | * @covers ::getProjectName 65 | */ 66 | public function testGetProjectName(): void 67 | { 68 | $this->assertEquals('drupal', $this->createIssue()->getProjectName()); 69 | 70 | $this->expectException(\DomainException::class); 71 | $this->expectExceptionMessage('Data missing for stubs.'); 72 | DrupalOrgIssue::fromStub((object) ['id' => 1])->getProjectName(); 73 | } 74 | 75 | /** 76 | * @covers ::getTitle 77 | */ 78 | public function testGetTitle(): void 79 | { 80 | $this->assertEquals('[PP-1] Implement a generic revision UI', $this->createIssue()->getTitle()); 81 | 82 | $this->expectException(\DomainException::class); 83 | $this->expectExceptionMessage('Data missing for stubs.'); 84 | DrupalOrgIssue::fromStub((object) ['id' => 1])->getTitle(); 85 | } 86 | 87 | /** 88 | * @covers ::getComments 89 | */ 90 | public function testGetComments(): void 91 | { 92 | $comments = $this->createIssue()->getComments(); 93 | $this->assertCount(8, $comments); 94 | $this->assertEquals(13370001, $comments[0]->id()); 95 | $this->assertEquals(13370002, $comments[1]->id()); 96 | $this->assertEquals(13370003, $comments[2]->id()); 97 | $this->assertEquals(13370004, $comments[3]->id()); 98 | $this->assertEquals(13370005, $comments[4]->id()); 99 | $this->assertEquals(13370006, $comments[5]->id()); 100 | $this->assertEquals(13370007, $comments[6]->id()); 101 | $this->assertEquals(13370008, $comments[7]->id()); 102 | } 103 | 104 | /** 105 | * @covers ::getFiles 106 | */ 107 | public function testGetFiles(): void 108 | { 109 | $this->assertEquals([ 110 | 22220001, 111 | 22220002, 112 | 22220003, 113 | 22220004, 114 | ], $this->createIssue()->getFiles()); 115 | } 116 | 117 | /** 118 | * @covers ::getPatches 119 | */ 120 | public function testGetPatches(): void 121 | { 122 | $issue = $this->createIssue(); 123 | $objectIterator = $this->prophesize(DrupalOrgObjectIterator::class); 124 | $objectIterator->unstubComments(new AnyValuesToken()) 125 | ->shouldBeCalledTimes(1) 126 | ->will(function ($args) { 127 | return array_map(function (DrupalOrgComment $comment) { 128 | $response = new Response(200, [], TestUtilities::getFixture(sprintf('comment-%s.json', $comment->id()))); 129 | $comment->importResponse($response); 130 | 131 | return $comment; 132 | }, $args[0]); 133 | }); 134 | $objectIterator->unstubFiles(new AnyValuesToken()) 135 | ->shouldBeCalledTimes(1) 136 | ->will(function ($args) { 137 | return array_map(function (DrupalOrgFile $file) { 138 | $response = new Response(200, [], TestUtilities::getFixture(sprintf('file-%s.json', $file->id()))); 139 | $file->importResponse($response); 140 | 141 | return $file; 142 | }, $args[0]); 143 | }); 144 | 145 | $patches = iterator_to_array($issue->getPatches($objectIterator->reveal()), false); 146 | 147 | $this->assertCount(3, $patches); 148 | } 149 | 150 | /** 151 | * @covers ::fromStub 152 | */ 153 | public function testFromStub(): void 154 | { 155 | $object = DrupalOrgIssue::fromStub((object) ['id' => 1]); 156 | $this->assertEquals(1, $object->id()); 157 | 158 | $this->expectException(\InvalidArgumentException::class); 159 | $this->expectExceptionMessage('ID is required'); 160 | DrupalOrgIssue::fromStub((object) [])->id(); 161 | } 162 | 163 | /** 164 | * @covers ::fromResponse 165 | */ 166 | public function testFromResponse(): void 167 | { 168 | $repository = new DrupalOrgObjectRepository(); 169 | $object = DrupalOrgIssue::fromResponse( 170 | new Response(200, [], TestUtilities::getFixture('issue.json')), 171 | $repository, 172 | ); 173 | $this->assertCount(0, iterator_to_array($repository->all(), false)); 174 | $this->assertEquals(1412524021, $object->getCreated()->getTimestamp()); 175 | } 176 | 177 | protected function createIssue(): DrupalOrgIssue 178 | { 179 | $repository = new DrupalOrgObjectRepository(); 180 | 181 | return DrupalOrgIssue::fromResponse( 182 | new Response(200, [], TestUtilities::getFixture('issue.json')), 183 | $repository, 184 | ); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /tests/DrupalOrg/Objects/DrupalOrgObjectTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('https://www.drupal.org/project/drupal/issues/2350939', $object->url()); 30 | } 31 | 32 | /** 33 | * @covers ::isStub 34 | */ 35 | public function testIsStub(): void 36 | { 37 | $object = DrupalOrgIssue::fromStub((object) ['id' => 1]); 38 | $this->assertTrue($object->isStub()); 39 | 40 | $object = DrupalOrgIssue::fromResponse( 41 | new Response(200, [], TestUtilities::getFixture('issue.json')), 42 | new DrupalOrgObjectRepository(), 43 | ); 44 | $this->assertFalse($object->isStub()); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/DrupalOrg/Objects/DrupalOrgPatchTest.php: -------------------------------------------------------------------------------- 1 | createFile(); 26 | $patch->setVersion('13.3.7'); 27 | $this->assertEquals('13.3.7', $patch->getVersion()); 28 | } 29 | 30 | /** 31 | * @covers ::getGitReference 32 | * @covers ::setGitReference 33 | */ 34 | public function testGitReference(): void 35 | { 36 | $patch = $this->createFile(); 37 | $patch->setGitReference('8.x-1.0'); 38 | $this->assertEquals('8.x-1.0', $patch->getGitReference()); 39 | } 40 | 41 | /** 42 | * @covers ::getContents 43 | * @covers ::setContents 44 | */ 45 | public function testContents(): void 46 | { 47 | $patch = $this->createFile(); 48 | $patch->setContents('foo'); 49 | $this->assertEquals('foo', $patch->getContents()); 50 | } 51 | 52 | /** 53 | * @covers ::fromFile 54 | */ 55 | public function testFromFile(): void 56 | { 57 | $file = DrupalOrgFile::fromStub((object) [ 58 | 'id' => 33, 59 | ]); 60 | $file->setRepository(new DrupalOrgObjectRepository()); 61 | 62 | $patch = DrupalOrgPatch::fromFile($file); 63 | $this->assertEquals(33, $patch->id()); 64 | } 65 | 66 | public function testParent(): void 67 | { 68 | $comment = new DrupalOrgComment(66); 69 | 70 | $file = $this->createFile(); 71 | $file->setParent($comment); 72 | /** @var DrupalOrgComment $parent */ 73 | $parent = $file->getParent(); 74 | $this->assertEquals(66, $parent->id()); 75 | } 76 | 77 | protected function createFile(?string $fixtureId = null): DrupalOrgPatch 78 | { 79 | $fixtureId = $fixtureId ?? '22220001'; 80 | $repository = new DrupalOrgObjectRepository(); 81 | 82 | return DrupalOrgPatch::fromResponse( 83 | new Response(200, [], TestUtilities::getFixture(sprintf('file-%s.json', $fixtureId))), 84 | $repository, 85 | ); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/Events/PatchToBranch/FilterByResponseEventTest.php: -------------------------------------------------------------------------------- 1 | createMock(LoggerInterface::class); 26 | 27 | $event = new FilterByResponseEvent($patches, $logger); 28 | $this->assertEquals($patches, $event->patches); 29 | $this->assertEquals($logger, $event->logger); 30 | } 31 | 32 | /** 33 | * @covers ::setFailure 34 | * @covers ::isPropagationStopped 35 | */ 36 | public function testIsPropagationStopped(): void 37 | { 38 | $logger = $this->createMock(LoggerInterface::class); 39 | 40 | $event = new FilterByResponseEvent([], $logger); 41 | $this->assertFalse($event->isPropagationStopped()); 42 | $event->setFailure(); 43 | $this->assertTrue($event->isPropagationStopped()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Events/PatchToBranch/FilterEventTest.php: -------------------------------------------------------------------------------- 1 | 1]); 29 | $issueEvents = [IssueEvent::fromRaw($comment, 'Foo', ['abc' => 123])]; 30 | $logger = $this->createMock(LoggerInterface::class); 31 | $options = new PatchToBranchOptions(); 32 | 33 | $event = new FilterEvent($patches, $issueEvents, $logger, $options); 34 | $this->assertEquals($patches, $event->patches); 35 | $this->assertEquals($issueEvents, $event->issueEvents); 36 | $this->assertEquals($logger, $event->logger); 37 | $this->assertEquals($options, $event->options); 38 | } 39 | 40 | /** 41 | * @covers ::setFailure 42 | * @covers ::isPropagationStopped 43 | */ 44 | public function testIsPropagationStopped(): void 45 | { 46 | $logger = $this->createMock(LoggerInterface::class); 47 | $options = new PatchToBranchOptions(); 48 | 49 | $event = new FilterEvent([], [], $logger, $options); 50 | $this->assertFalse($event->isPropagationStopped()); 51 | $event->setFailure(); 52 | $this->assertTrue($event->isPropagationStopped()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Events/PatchToBranch/VersionEventTest.php: -------------------------------------------------------------------------------- 1 | 1]); 29 | $issueEvents = [IssueEvent::fromRaw($comment, 'Foo', ['abc' => 123])]; 30 | $objectIterator = $this->createMock(DrupalOrgObjectIterator::class); 31 | $logger = $this->createMock(LoggerInterface::class); 32 | 33 | $event = new VersionEvent($patches, $issueEvents, $objectIterator, $logger); 34 | $this->assertEquals($patches, $event->patches); 35 | $this->assertEquals($issueEvents, $event->issueEvents); 36 | $this->assertEquals($objectIterator, $event->objectIterator); 37 | $this->assertEquals($logger, $event->logger); 38 | } 39 | 40 | /** 41 | * @covers ::setFailure 42 | * @covers ::isPropagationStopped 43 | */ 44 | public function testIsPropagationStopped(): void 45 | { 46 | $objectIterator = $this->createMock(DrupalOrgObjectIterator::class); 47 | $logger = $this->createMock(LoggerInterface::class); 48 | 49 | $event = new VersionEvent([], [], $objectIterator, $logger); 50 | $this->assertFalse($event->isPropagationStopped()); 51 | $event->setFailure(); 52 | $this->assertTrue($event->isPropagationStopped()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Git/GitCliRunnerTest.php: -------------------------------------------------------------------------------- 1 | createMock(LoggerInterface::class); 25 | $logger->expects($this->once()) 26 | ->method('debug') 27 | ->with('Executing command: {command}', [ 28 | 'command' => 'git foo hello world', 29 | ]); 30 | 31 | $cliRunner = new CliRunner(logger: $logger); 32 | $this->expectException(GitException::class); 33 | $this->expectExceptionMessage("Directory 'testcwd' not found"); 34 | $cliRunner->run('testcwd', [ 35 | 'foo', 36 | ['hello' => 'world'], 37 | ]); 38 | } 39 | 40 | /** 41 | * @covers ::setLogger 42 | */ 43 | public function testSetLogger(): void 44 | { 45 | $cliRunner = new CliRunner(); 46 | $logger = $this->createMock(LoggerInterface::class); 47 | $logger->expects($this->once()) 48 | ->method('debug') 49 | ->with('Executing command: {command}', [ 50 | 'command' => 'git foo hello world', 51 | ]); 52 | 53 | $cliRunner->setLogger($logger); 54 | $this->expectException(GitException::class); 55 | $this->expectExceptionMessage("Directory 'testcwd' not found"); 56 | $cliRunner->run('testcwd', [ 57 | 'foo', 58 | ['hello' => 'world'], 59 | ]); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Git/GitOperatorTest.php: -------------------------------------------------------------------------------- 1 | createMock(GitRepository::class); 24 | $gitRepository->expects($this->once()) 25 | ->method('execute') 26 | ->with('status', '--porcelain') 27 | ->willReturn([]); 28 | $gitOperator = new GitOperator($gitRepository); 29 | $this->assertTrue($gitOperator->isClean()); 30 | } 31 | 32 | /** 33 | * @covers ::isClean 34 | */ 35 | public function testIsNotClean(): void 36 | { 37 | $gitRepository = $this->createMock(GitRepository::class); 38 | $gitRepository->expects($this->once()) 39 | ->method('execute') 40 | ->with('status', '--porcelain') 41 | ->willReturn(['?? hey.txt']); 42 | $gitOperator = new GitOperator($gitRepository); 43 | $this->assertFalse($gitOperator->isClean()); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Git/GitResolverTest.php: -------------------------------------------------------------------------------- 1 | createMock(GitRepository::class); 30 | $gitRepository->expects($this->once()) 31 | ->method('execute') 32 | ->with([ 33 | 'rev-list', 34 | '-1', 35 | '--before="1428704703"', 36 | 'remotes/origin/8.x-1.0', 37 | ]) 38 | ->willReturn(['cccccccccc000000000000000000000000000001']); 39 | $gitOperator = new GitOperator($gitRepository); 40 | $patch = DrupalOrgPatch::fromResponse( 41 | new Response(200, [], TestUtilities::getFixture('file-22220001.json')), 42 | new DrupalOrgObjectRepository(), 43 | ); 44 | $patch->setGitReference('8.x-1.0'); 45 | $gitResolver = new GitResolver($patch, $gitOperator); 46 | $this->assertEquals('cccccccccc000000000000000000000000000001', $gitResolver->getHash()); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/Listeners/PatchToBranch/Filter/ByMetadataTest.php: -------------------------------------------------------------------------------- 1 | 101]) 32 | ->setSequence(1); 33 | $comment2 = DrupalOrgComment::fromStub((object) ['id' => 102]) 34 | ->setSequence(2); 35 | $comment3 = DrupalOrgComment::fromStub((object) ['id' => 103]) 36 | ->setSequence(3); 37 | $comment4 = DrupalOrgComment::fromStub((object) ['id' => 104]) 38 | ->setSequence(4); 39 | 40 | $patches = []; 41 | $patches[] = (new DrupalOrgPatch(201)) 42 | ->setParent($comment1) 43 | ->importResponse(new Response(200, [], (string) \json_encode([ 44 | 'url' => 'http://example.com/patch1.patch', 45 | ]))); 46 | $patches[] = (new DrupalOrgPatch(202)) 47 | ->setParent($comment2) 48 | ->importResponse(new Response(200, [], (string) \json_encode([ 49 | 'url' => 'http://example.com/patch2-interdiff.txt', 50 | ]))); 51 | $patches[] = (new DrupalOrgPatch(203)) 52 | ->setParent($comment3) 53 | ->importResponse(new Response(200, [], (string) \json_encode([ 54 | 'url' => 'http://example.com/patch3.patch', 55 | ]))); 56 | $patches[] = (new DrupalOrgPatch(204)) 57 | ->setParent($comment4) 58 | ->importResponse(new Response(200, [], (string) \json_encode([ 59 | 'url' => 'http://example.com/test-only-patch4.patch', 60 | ]))); 61 | 62 | $issueEvents = []; 63 | $issueEvents[] = IssueEvent::fromRaw($comment1, 'Foo', ['abc' => 123]); 64 | $issueEvents[] = new TestResultEvent($comment2, '1.0.x', 'PHP 7.1 & MySQL 5.7 26,400 pass'); 65 | $issueEvents[] = new TestResultEvent($comment3, '1.1.x', 'Unable to apply patch blah-blah.patch. Unable to apply patch. See the log in the details link for more information.'); 66 | $issueEvents[] = new TestResultEvent($comment4, '1.2.x', 'PHP 5.5 & MySQL 5.5 14,068 pass, 7 fail'); 67 | 68 | $logger = \Mockery::mock(LoggerInterface::class); 69 | $logger->expects('debug')->with('Comment #{comment_id}: {patch_url} looks like an interdiff', [ 70 | 'comment_id' => 2, 71 | 'patch_url' => 'http://example.com/patch2-interdiff.txt', 72 | ]); 73 | $logger->expects('debug')->with('Comment #{comment_id}: {patch_url} failed to apply during test run.', [ 74 | 'comment_id' => 3, 75 | 'patch_url' => 'http://example.com/patch3.patch', 76 | ]); 77 | $logger->expects('debug')->with('Comment #{comment_id}: {patch_url} looks like a test only patch', [ 78 | 'comment_id' => 4, 79 | 'patch_url' => 'http://example.com/test-only-patch4.patch', 80 | ]); 81 | $logger->expects('info')->with('Filtering patches by patch metadata.'); 82 | 83 | $options = new PatchToBranchOptions(); 84 | 85 | $event = new FilterEvent($patches, $issueEvents, $logger, $options); 86 | $filter = new ByMetadata(); 87 | 88 | $this->assertCount(4, $event->patches); 89 | $filter($event); 90 | $this->assertCount(1, $event->patches); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/Listeners/PatchToBranch/FilterByResponse/ByBodyTest.php: -------------------------------------------------------------------------------- 1 | 101]) 28 | ->setSequence(1); 29 | $comment2 = DrupalOrgComment::fromStub((object) ['id' => 102]) 30 | ->setSequence(2); 31 | 32 | $patches = []; 33 | $patches[] = (new DrupalOrgPatch(201)) 34 | ->setParent($comment1) 35 | ->setContents('patch 1 contents') 36 | ->importResponse(new Response(200, [], (string) \json_encode([ 37 | 'url' => 'http://example.com/patch1.patch', 38 | ]))); 39 | $patches[] = (new DrupalOrgPatch(202)) 40 | ->setParent($comment2) 41 | // Intentionally empty: 42 | ->setContents('') 43 | ->importResponse(new Response(200, [], (string) \json_encode([ 44 | 'url' => 'http://example.com/patch2.patch', 45 | ]))); 46 | 47 | $logger = $this->createMock(LoggerInterface::class); 48 | $logger->expects($this->exactly(1)) 49 | ->method('debug') 50 | ->with('Removed empty patch #{comment_id} {patch_url}.', [ 51 | 'comment_id' => 2, 52 | 'patch_url' => 'http://example.com/patch2.patch', 53 | ]); 54 | 55 | $event = new FilterByResponseEvent($patches, $logger); 56 | $filter = new ByBody(); 57 | 58 | $this->assertCount(2, $event->patches); 59 | $filter($event); 60 | $this->assertCount(1, $event->patches); 61 | } 62 | 63 | /** 64 | * @covers ::__invoke 65 | */ 66 | public function testFilterMissingPatchContents(): void 67 | { 68 | $comment1 = DrupalOrgComment::fromStub((object) ['id' => 101]) 69 | ->setSequence(1); 70 | 71 | $patches = []; 72 | $patches[] = (new DrupalOrgPatch(201)) 73 | ->setParent($comment1) 74 | ->importResponse(new Response(200, [], (string) \json_encode([ 75 | 'url' => 'http://example.com/patch1.patch', 76 | ]))); 77 | 78 | $logger = $this->createMock(LoggerInterface::class); 79 | 80 | $event = new FilterByResponseEvent($patches, $logger); 81 | $filter = new ByBody(); 82 | 83 | $this->expectException(\LogicException::class); 84 | $this->expectExceptionMessage('Missing patch contents for patch #1 http://example.com/patch1.patch'); 85 | $filter($event); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/ProcessFactoryTest.php: -------------------------------------------------------------------------------- 1 | createProcess(['ls'], '/tmp/testdirectory'); 24 | $this->assertEquals("'ls'", $process->getCommandLine()); 25 | $this->assertEquals('/tmp/testdirectory', $process->getWorkingDirectory()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/TestUtilities.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 |
9 |
10 |
11 |
12 | 13 | -------------------------------------------------------------------------------- /tests/fixtures/issue-11110002.json: -------------------------------------------------------------------------------- 1 | { 2 | "taxonomy_vocabulary_9": [], 3 | "body": { 4 | "value": "Blah", 5 | "summary": "", 6 | "format": "1" 7 | }, 8 | "field_issue_status": "8", 9 | "field_issue_priority": "200", 10 | "field_issue_category": "3", 11 | "field_issue_component": "entity system", 12 | "field_project": { 13 | "uri": "https:\/\/www.drupal.org\/api-d7\/node\/3060", 14 | "id": "3060", 15 | "resource": "node", 16 | "machine_name": "drupal" 17 | }, 18 | "field_issue_files": [ 19 | ], 20 | "field_issue_related": [ 21 | ], 22 | "field_issue_version": "9.3.x-dev", 23 | "field_issue_credit": [], 24 | "field_issue_last_status_change": "1623906919", 25 | "flag_project_issue_follow_user": { 26 | }, 27 | "nid": "11110002", 28 | "vid": "11110002", 29 | "is_new": false, 30 | "type": "project_issue", 31 | "title": "Issue with no merge requests", 32 | "language": "und", 33 | "url": "https:\/\/www.drupal.org\/project\/drupal\/issues\/11110002", 34 | "edit_url": "https:\/\/www.drupal.org\/node\/2350939\/edit", 35 | "status": "1", 36 | "promote": "0", 37 | "sticky": "0", 38 | "created": "1412524021", 39 | "changed": "1623906919", 40 | "author": { 41 | "uri": "https:\/\/www.drupal.org\/api-d7\/user\/1111111111", 42 | "id": "1111111111", 43 | "resource": "user" 44 | }, 45 | "log": "", 46 | "revision": null, 47 | "book_ancestors": [], 48 | "comment": "2", 49 | "comments": [ 50 | ], 51 | "comment_count": "100", 52 | "comment_count_new": "8", 53 | "feeds_item_guid": null, 54 | "feeds_item_url": null, 55 | "feed_nid": null, 56 | "flag_flag_tracker_follow_user": [], 57 | "flag_tracker_follower_count": "143", 58 | "has_new_content": " \u003Cspan class=\u0022marker\u0022\u003Eupdated\u003C\/span\u003E", 59 | "last_comment_timestamp": "1623906919" 60 | } -------------------------------------------------------------------------------- /tests/fixtures/issue-11110003.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 |
9 |
10 |

Issue fork drupal-11110003

11 | 12 | 17 |
18 | 19 |
20 |
21 | 24 |
25 |
26 |
27 |

dries opened merge request !333

28 |
29 |
30 |
31 |
32 |
33 |
34 | 35 | -------------------------------------------------------------------------------- /tests/fixtures/issue-11110003.json: -------------------------------------------------------------------------------- 1 | { 2 | "taxonomy_vocabulary_9": [], 3 | "body": { 4 | "value": "Blah", 5 | "summary": "", 6 | "format": "1" 7 | }, 8 | "field_issue_status": "8", 9 | "field_issue_priority": "200", 10 | "field_issue_category": "3", 11 | "field_issue_component": "entity system", 12 | "field_project": { 13 | "uri": "https:\/\/www.drupal.org\/api-d7\/node\/3060", 14 | "id": "3060", 15 | "resource": "node", 16 | "machine_name": "drupal" 17 | }, 18 | "field_issue_files": [ 19 | ], 20 | "field_issue_related": [ 21 | ], 22 | "field_issue_version": "9.3.x-dev", 23 | "field_issue_credit": [], 24 | "field_issue_last_status_change": "1623906919", 25 | "flag_project_issue_follow_user": { 26 | }, 27 | "nid": "11110003", 28 | "vid": "11110003", 29 | "is_new": false, 30 | "type": "project_issue", 31 | "title": "Issue for testing with merge requests", 32 | "language": "und", 33 | "url": "https:\/\/www.drupal.org\/project\/drupal\/issues\/11110003", 34 | "edit_url": "https:\/\/www.drupal.org\/node\/2350939\/edit", 35 | "status": "1", 36 | "promote": "0", 37 | "sticky": "0", 38 | "created": "1412524021", 39 | "changed": "1623906919", 40 | "author": { 41 | "uri": "https:\/\/www.drupal.org\/api-d7\/user\/1111111111", 42 | "id": "1111111111", 43 | "resource": "user" 44 | }, 45 | "log": "", 46 | "revision": null, 47 | "book_ancestors": [], 48 | "comment": "2", 49 | "comments": [ 50 | ], 51 | "comment_count": "100", 52 | "comment_count_new": "8", 53 | "feeds_item_guid": null, 54 | "feeds_item_url": null, 55 | "feed_nid": null, 56 | "flag_flag_tracker_follow_user": [], 57 | "flag_tracker_follower_count": "143", 58 | "has_new_content": " \u003Cspan class=\u0022marker\u0022\u003Eupdated\u003C\/span\u003E", 59 | "last_comment_timestamp": "1623906919" 60 | } -------------------------------------------------------------------------------- /tests/fixtures/test.patch: -------------------------------------------------------------------------------- 1 | This is a sample patch file. --------------------------------------------------------------------------------